1179 lines
30 KiB
Vue
1179 lines
30 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||
// Inline SVG markup imported as raw strings via Vite's `?raw` suffix.
|
||
// Rationale: rendering through v-html lets the icon inherit `currentColor`
|
||
// from the toolbar button, so styling stays in CSS without bundling extra
|
||
// SVG-as-component plugins.
|
||
import cancelIcon from '../assets/icons/cancel.svg?raw'
|
||
import copyIcon from '../assets/icons/copy.svg?raw'
|
||
import saveIcon from '../assets/icons/save-local.svg?raw'
|
||
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
|
||
import uploadIcon from '../assets/icons/upload.svg?raw'
|
||
|
||
interface Props {
|
||
width: number
|
||
height: number
|
||
}
|
||
const props = defineProps<Props>()
|
||
|
||
interface Rect {
|
||
x: number
|
||
y: number
|
||
w: number
|
||
h: number
|
||
}
|
||
|
||
type Tool = 'pen' | 'rect' | 'ellipse' | 'text'
|
||
interface Point {
|
||
x: number
|
||
y: number
|
||
}
|
||
interface Annotation {
|
||
tool: Tool
|
||
color: string
|
||
points: Point[]
|
||
text?: string
|
||
fontSize?: number
|
||
}
|
||
|
||
const emit = defineEmits<{
|
||
(
|
||
e: 'confirm',
|
||
payload: { rect: Rect; annotations: Annotation[] }
|
||
): void
|
||
(
|
||
e: 'copy',
|
||
payload: { rect: Rect; annotations: Annotation[] }
|
||
): void
|
||
(
|
||
e: 'save',
|
||
payload: { rect: Rect; annotations: Annotation[] }
|
||
): void
|
||
(
|
||
e: 'save-remote',
|
||
payload: { rect: Rect; annotations: Annotation[] }
|
||
): void
|
||
(
|
||
e: 'summarize',
|
||
payload: { rect: Rect; annotations: Annotation[] }
|
||
): void
|
||
(e: 'cancel'): void
|
||
}>()
|
||
|
||
const rect = ref<Rect | null>(null)
|
||
const annotations = ref<Annotation[]>([])
|
||
const draftAnnotation = ref<Annotation | null>(null)
|
||
const activeTool = ref<Tool>('pen')
|
||
const activeColor = ref('#ef4444')
|
||
const paletteOpen = ref(false)
|
||
const textDraft = ref<{
|
||
point: Point
|
||
value: string
|
||
color: string
|
||
index: number | null
|
||
} | null>(null)
|
||
const textInputRef = ref<HTMLInputElement | null>(null)
|
||
const selectedTextIndex = ref<number | null>(null)
|
||
|
||
type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se'
|
||
type DragMode =
|
||
| 'idle'
|
||
| 'creating'
|
||
| 'moving'
|
||
| 'resizing'
|
||
| 'annotating'
|
||
| 'moving-text'
|
||
const dragMode = ref<DragMode>('idle')
|
||
const resizeHandle = ref<ResizeHandle | null>(null)
|
||
const dragAnchor = ref({ x: 0, y: 0 })
|
||
const startRect = ref<Rect | null>(null)
|
||
const textDragStartPoint = ref<Point | null>(null)
|
||
const textDragOriginalPoint = ref<Point | null>(null)
|
||
|
||
const TEXT_FONT_SIZE = 20
|
||
|
||
const colors = [
|
||
'#ef4444',
|
||
'#f97316',
|
||
'#facc15',
|
||
'#22c55e',
|
||
'#06b6d4',
|
||
'#3b82f6',
|
||
'#8b5cf6',
|
||
'#ec4899',
|
||
'#ffffff',
|
||
'#111827',
|
||
]
|
||
|
||
const maskPath = computed(() => {
|
||
const outer = `M0 0 H${props.width} V${props.height} H0 Z`
|
||
if (!rect.value) return outer
|
||
const r = rect.value
|
||
const inner = `M${r.x} ${r.y} H${r.x + r.w} V${r.y + r.h} H${r.x} Z`
|
||
return outer + ' ' + inner
|
||
})
|
||
|
||
const allAnnotations = computed(() => {
|
||
return draftAnnotation.value
|
||
? [...annotations.value, draftAnnotation.value]
|
||
: annotations.value
|
||
})
|
||
|
||
const selectionAnnotations = computed(() => {
|
||
if (!rect.value) return []
|
||
return allAnnotations.value.map((annotation) => ({
|
||
...annotation,
|
||
points: annotation.points.map((point) => ({
|
||
x: rect.value!.x + point.x,
|
||
y: rect.value!.y + point.y,
|
||
})),
|
||
}))
|
||
})
|
||
|
||
const sizeLabel = computed(() => {
|
||
if (!rect.value) return ''
|
||
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
|
||
})
|
||
|
||
const MARK_TOOLBAR_W = 222
|
||
const ACTION_TOOLBAR_W = 216
|
||
const TOOLBAR_GROUP_GAP = 8
|
||
|
||
const rightToolbarPos = computed(() => {
|
||
if (!rect.value) return null
|
||
return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right')
|
||
})
|
||
|
||
const leftToolbarPos = computed(() => {
|
||
if (!rect.value || !rightToolbarPos.value) return null
|
||
// 标记组与操作组合并到右侧,紧贴在操作组左侧,中间保留间隔
|
||
const x = clamp(
|
||
rightToolbarPos.value.x - TOOLBAR_GROUP_GAP - MARK_TOOLBAR_W,
|
||
0,
|
||
props.width - MARK_TOOLBAR_W
|
||
)
|
||
return { x, y: rightToolbarPos.value.y }
|
||
})
|
||
|
||
const handles = computed(() => {
|
||
if (!rect.value) return []
|
||
const r = rect.value
|
||
const cx = r.x + r.w / 2
|
||
const cy = r.y + r.h / 2
|
||
return [
|
||
{ name: 'nw', x: r.x, y: r.y },
|
||
{ name: 'n', x: cx, y: r.y },
|
||
{ name: 'ne', x: r.x + r.w, y: r.y },
|
||
{ name: 'e', x: r.x + r.w, y: cy },
|
||
{ name: 'se', x: r.x + r.w, y: r.y + r.h },
|
||
{ name: 's', x: cx, y: r.y + r.h },
|
||
{ name: 'sw', x: r.x, y: r.y + r.h },
|
||
{ name: 'w', x: r.x, y: cy },
|
||
] as Array<{ name: ResizeHandle; x: number; y: number }>
|
||
})
|
||
|
||
function placeToolbar(
|
||
r: Rect,
|
||
toolbarW: number,
|
||
toolbarH: number,
|
||
side: 'left' | 'right'
|
||
) {
|
||
const gap = 8
|
||
let x =
|
||
side === 'right'
|
||
? r.x + r.w - toolbarW
|
||
: r.x
|
||
let y = r.y + r.h + gap
|
||
if (y + toolbarH > props.height) {
|
||
y = r.y + r.h - toolbarH - gap
|
||
}
|
||
x = clamp(x, 0, props.width - toolbarW)
|
||
return { x, y }
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(value, max))
|
||
}
|
||
|
||
function normalizeRect(a: Point, b: Point): Rect {
|
||
return {
|
||
x: clamp(Math.min(a.x, b.x), 0, props.width),
|
||
y: clamp(Math.min(a.y, b.y), 0, props.height),
|
||
w: Math.abs(b.x - a.x),
|
||
h: Math.abs(b.y - a.y),
|
||
}
|
||
}
|
||
|
||
function pointFromEvent(e: MouseEvent): Point {
|
||
return {
|
||
x: clamp(e.clientX, 0, props.width),
|
||
y: clamp(e.clientY, 0, props.height),
|
||
}
|
||
}
|
||
|
||
function localPoint(p: Point) {
|
||
if (!rect.value) return p
|
||
return {
|
||
x: clamp(p.x - rect.value.x, 0, rect.value.w),
|
||
y: clamp(p.y - rect.value.y, 0, rect.value.h),
|
||
}
|
||
}
|
||
|
||
function insideRect(p: Point, r: Rect | null) {
|
||
if (!r) return false
|
||
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h
|
||
}
|
||
|
||
let measureCanvas: HTMLCanvasElement | null = null
|
||
|
||
function textFont(fontSize = TEXT_FONT_SIZE) {
|
||
return `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif`
|
||
}
|
||
|
||
function textSize(annotation: Pick<Annotation, 'text' | 'fontSize'>) {
|
||
const fontSize = annotation.fontSize || TEXT_FONT_SIZE
|
||
const lines = (annotation.text || ' ').split('\n')
|
||
if (typeof document === 'undefined') {
|
||
return {
|
||
w: Math.max(1, ...lines.map((line) => line.length * fontSize)),
|
||
h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2),
|
||
}
|
||
}
|
||
measureCanvas ||= document.createElement('canvas')
|
||
const ctx = measureCanvas.getContext('2d')
|
||
if (!ctx) {
|
||
return {
|
||
w: Math.max(1, ...lines.map((line) => line.length * fontSize)),
|
||
h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2),
|
||
}
|
||
}
|
||
ctx.font = textFont(fontSize)
|
||
return {
|
||
w: Math.ceil(Math.max(1, ...lines.map((line) => ctx.measureText(line || ' ').width))),
|
||
h: Math.ceil(Math.max(fontSize * 1.2, lines.length * fontSize * 1.2)),
|
||
}
|
||
}
|
||
|
||
function textRenderBox(annotation: Annotation) {
|
||
const point = annotation.points[0] || { x: 0, y: 0 }
|
||
const size = textSize(annotation)
|
||
return {
|
||
x: point.x - 4,
|
||
y: point.y - 3,
|
||
w: size.w + 8,
|
||
h: size.h + 6,
|
||
}
|
||
}
|
||
|
||
function textAnnotationIndexAtPoint(p: Point) {
|
||
if (!rect.value) return null
|
||
for (let i = annotations.value.length - 1; i >= 0; i--) {
|
||
const annotation = annotations.value[i]
|
||
if (annotation.tool !== 'text' || annotation.points.length === 0) continue
|
||
const point = annotation.points[0]
|
||
const size = textSize(annotation)
|
||
const x = rect.value.x + point.x
|
||
const y = rect.value.y + point.y
|
||
if (
|
||
p.x >= x - 6 &&
|
||
p.x <= x + size.w + 6 &&
|
||
p.y >= y - 6 &&
|
||
p.y <= y + size.h + 6
|
||
) {
|
||
return i
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function clampTextLocalPoint(point: Point, annotation: Pick<Annotation, 'text' | 'fontSize'>) {
|
||
if (!rect.value) return point
|
||
const size = textSize(annotation)
|
||
return {
|
||
x: clamp(point.x, 0, Math.max(0, rect.value.w - size.w)),
|
||
y: clamp(point.y, 0, Math.max(0, rect.value.h - size.h)),
|
||
}
|
||
}
|
||
|
||
function hitHandle(p: Point): ResizeHandle | null {
|
||
if (!rect.value) return null
|
||
const tolerance = 9
|
||
for (const handle of handles.value) {
|
||
if (
|
||
Math.abs(p.x - handle.x) <= tolerance &&
|
||
Math.abs(p.y - handle.y) <= tolerance
|
||
) {
|
||
return handle.name
|
||
}
|
||
}
|
||
const r = rect.value
|
||
const nearX = p.x >= r.x - tolerance && p.x <= r.x + r.w + tolerance
|
||
const nearY = p.y >= r.y - tolerance && p.y <= r.y + r.h + tolerance
|
||
if (nearX && Math.abs(p.y - r.y) <= tolerance) return 'n'
|
||
if (nearX && Math.abs(p.y - (r.y + r.h)) <= tolerance) return 's'
|
||
if (nearY && Math.abs(p.x - r.x) <= tolerance) return 'w'
|
||
if (nearY && Math.abs(p.x - (r.x + r.w)) <= tolerance) return 'e'
|
||
return null
|
||
}
|
||
|
||
function onMouseDown(e: MouseEvent) {
|
||
if (e.button !== 0) return
|
||
commitTextDraft()
|
||
paletteOpen.value = false
|
||
textDragStartPoint.value = null
|
||
textDragOriginalPoint.value = null
|
||
const p = pointFromEvent(e)
|
||
const handle = hitHandle(p)
|
||
if (handle && rect.value) {
|
||
dragMode.value = 'resizing'
|
||
resizeHandle.value = handle
|
||
dragAnchor.value = p
|
||
startRect.value = { ...rect.value }
|
||
return
|
||
}
|
||
if (rect.value && insideRect(p, rect.value)) {
|
||
dragMode.value = 'moving'
|
||
dragAnchor.value = { x: p.x - rect.value.x, y: p.y - rect.value.y }
|
||
return
|
||
}
|
||
dragMode.value = 'creating'
|
||
dragAnchor.value = p
|
||
rect.value = { x: p.x, y: p.y, w: 0, h: 0 }
|
||
annotations.value = []
|
||
selectedTextIndex.value = null
|
||
draftAnnotation.value = null
|
||
}
|
||
|
||
function onSelectionMouseDown(e: MouseEvent) {
|
||
if (e.button !== 0 || !rect.value) return
|
||
e.stopPropagation()
|
||
commitTextDraft()
|
||
paletteOpen.value = false
|
||
const p = pointFromEvent(e)
|
||
const hitTextIndex = textAnnotationIndexAtPoint(p)
|
||
if (hitTextIndex !== null) {
|
||
selectedTextIndex.value = hitTextIndex
|
||
const annotation = annotations.value[hitTextIndex]
|
||
if (e.detail >= 2) {
|
||
beginTextAnnotation(annotation.points[0], hitTextIndex)
|
||
return
|
||
}
|
||
dragMode.value = 'moving-text'
|
||
textDragStartPoint.value = localPoint(p)
|
||
textDragOriginalPoint.value = { ...annotation.points[0] }
|
||
return
|
||
}
|
||
selectedTextIndex.value = null
|
||
if (activeTool.value === 'text') {
|
||
beginTextAnnotation(localPoint(p))
|
||
return
|
||
}
|
||
if (e.detail >= 2) {
|
||
removeSinglePointAnnotation()
|
||
onCopy()
|
||
return
|
||
}
|
||
const handle = hitHandle(p)
|
||
if (handle) {
|
||
dragMode.value = 'resizing'
|
||
resizeHandle.value = handle
|
||
dragAnchor.value = p
|
||
startRect.value = { ...rect.value }
|
||
return
|
||
}
|
||
dragMode.value = 'annotating'
|
||
const local = localPoint(p)
|
||
draftAnnotation.value = {
|
||
tool: activeTool.value,
|
||
color: activeColor.value,
|
||
points: [local],
|
||
}
|
||
}
|
||
|
||
function onMouseMove(e: MouseEvent) {
|
||
if (dragMode.value === 'idle') return
|
||
const p = pointFromEvent(e)
|
||
if (dragMode.value === 'creating') {
|
||
rect.value = normalizeRect(dragAnchor.value, p)
|
||
} else if (dragMode.value === 'moving' && rect.value) {
|
||
rect.value = {
|
||
...rect.value,
|
||
x: clamp(p.x - dragAnchor.value.x, 0, props.width - rect.value.w),
|
||
y: clamp(p.y - dragAnchor.value.y, 0, props.height - rect.value.h),
|
||
}
|
||
} else if (dragMode.value === 'resizing') {
|
||
resizeSelection(p)
|
||
} else if (
|
||
dragMode.value === 'moving-text' &&
|
||
selectedTextIndex.value !== null &&
|
||
textDragStartPoint.value &&
|
||
textDragOriginalPoint.value
|
||
) {
|
||
const annotation = annotations.value[selectedTextIndex.value]
|
||
if (annotation?.tool === 'text') {
|
||
const local = localPoint(p)
|
||
const next = {
|
||
x: textDragOriginalPoint.value.x + local.x - textDragStartPoint.value.x,
|
||
y: textDragOriginalPoint.value.y + local.y - textDragStartPoint.value.y,
|
||
}
|
||
annotation.points[0] = clampTextLocalPoint(next, annotation)
|
||
}
|
||
} else if (dragMode.value === 'annotating' && draftAnnotation.value) {
|
||
if (activeTool.value === 'pen') {
|
||
draftAnnotation.value.points.push(localPoint(p))
|
||
} else {
|
||
draftAnnotation.value.points = [
|
||
draftAnnotation.value.points[0],
|
||
localPoint(p),
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
function onMouseUp() {
|
||
if (dragMode.value === 'creating' && rect.value) {
|
||
if (rect.value.w < 4 || rect.value.h < 4) {
|
||
rect.value = null
|
||
}
|
||
}
|
||
if (dragMode.value === 'annotating' && draftAnnotation.value) {
|
||
if (draftAnnotation.value.points.length > 0) {
|
||
annotations.value.push(draftAnnotation.value)
|
||
}
|
||
draftAnnotation.value = null
|
||
}
|
||
dragMode.value = 'idle'
|
||
resizeHandle.value = null
|
||
startRect.value = null
|
||
textDragStartPoint.value = null
|
||
textDragOriginalPoint.value = null
|
||
}
|
||
|
||
function resizeSelection(p: Point) {
|
||
if (!startRect.value || !resizeHandle.value) return
|
||
const r = startRect.value
|
||
let left = r.x
|
||
let top = r.y
|
||
let right = r.x + r.w
|
||
let bottom = r.y + r.h
|
||
if (resizeHandle.value.includes('w')) left = p.x
|
||
if (resizeHandle.value.includes('e')) right = p.x
|
||
if (resizeHandle.value.includes('n')) top = p.y
|
||
if (resizeHandle.value.includes('s')) bottom = p.y
|
||
|
||
left = clamp(left, 0, props.width)
|
||
right = clamp(right, 0, props.width)
|
||
top = clamp(top, 0, props.height)
|
||
bottom = clamp(bottom, 0, props.height)
|
||
rect.value = {
|
||
x: Math.min(left, right),
|
||
y: Math.min(top, bottom),
|
||
w: Math.abs(right - left),
|
||
h: Math.abs(bottom - top),
|
||
}
|
||
}
|
||
|
||
function selectTool(tool: Tool) {
|
||
activeTool.value = tool
|
||
}
|
||
|
||
function chooseColor(color: string) {
|
||
activeColor.value = color
|
||
paletteOpen.value = false
|
||
}
|
||
|
||
function onConfirm() {
|
||
emitAction('confirm')
|
||
}
|
||
|
||
function onCopy() {
|
||
emitAction('copy')
|
||
}
|
||
|
||
function onSave() {
|
||
emitAction('save')
|
||
}
|
||
|
||
function onSaveRemote() {
|
||
emitAction('save-remote')
|
||
}
|
||
|
||
function onSummarize() {
|
||
emitAction('summarize')
|
||
}
|
||
|
||
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
|
||
if (!rect.value) return
|
||
commitTextDraft()
|
||
const payload = {
|
||
rect: { ...rect.value },
|
||
annotations: annotations.value,
|
||
}
|
||
if (action === 'confirm') emit('confirm', payload)
|
||
if (action === 'copy') emit('copy', payload)
|
||
if (action === 'save') emit('save', payload)
|
||
if (action === 'save-remote') emit('save-remote', payload)
|
||
if (action === 'summarize') emit('summarize', payload)
|
||
}
|
||
|
||
function onCancel() {
|
||
emit('cancel')
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (textDraft.value) {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
cancelTextDraft()
|
||
}
|
||
return
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
onCancel()
|
||
} else if (e.key === 'Enter' && rect.value) {
|
||
e.preventDefault()
|
||
onConfirm()
|
||
}
|
||
}
|
||
|
||
function undoAnnotation() {
|
||
cancelTextDraft()
|
||
annotations.value.pop()
|
||
if (
|
||
selectedTextIndex.value !== null &&
|
||
selectedTextIndex.value >= annotations.value.length
|
||
) {
|
||
selectedTextIndex.value = null
|
||
}
|
||
}
|
||
|
||
function removeSinglePointAnnotation() {
|
||
const last = annotations.value[annotations.value.length - 1]
|
||
if (last && last.tool === activeTool.value && last.points.length <= 1) {
|
||
annotations.value.pop()
|
||
}
|
||
}
|
||
|
||
function beginTextAnnotation(point: Point, index: number | null = null) {
|
||
commitTextDraft()
|
||
dragMode.value = 'idle'
|
||
draftAnnotation.value = null
|
||
const existing = index !== null ? annotations.value[index] : null
|
||
const color = existing?.color || activeColor.value
|
||
textDraft.value = {
|
||
point: clampTextLocalPoint(point, existing || { text: '', fontSize: TEXT_FONT_SIZE }),
|
||
value: existing?.text || '',
|
||
color,
|
||
index,
|
||
}
|
||
void nextTick(() => {
|
||
textInputRef.value?.focus()
|
||
textInputRef.value?.select()
|
||
})
|
||
}
|
||
|
||
function commitTextDraft() {
|
||
if (!textDraft.value) return
|
||
const text = textDraft.value.value.trim()
|
||
const draft = textDraft.value
|
||
if (draft.index !== null) {
|
||
const target = annotations.value[draft.index]
|
||
if (target?.tool === 'text') {
|
||
if (text !== '') {
|
||
target.points = [clampTextLocalPoint(draft.point, { text, fontSize: target.fontSize || TEXT_FONT_SIZE })]
|
||
target.text = text
|
||
target.color = draft.color
|
||
target.fontSize = target.fontSize || TEXT_FONT_SIZE
|
||
selectedTextIndex.value = draft.index
|
||
} else {
|
||
annotations.value.splice(draft.index, 1)
|
||
selectedTextIndex.value = null
|
||
}
|
||
}
|
||
} else if (text !== '') {
|
||
const point = clampTextLocalPoint(draft.point, { text, fontSize: TEXT_FONT_SIZE })
|
||
annotations.value.push({
|
||
tool: 'text',
|
||
color: draft.color,
|
||
points: [point],
|
||
text,
|
||
fontSize: TEXT_FONT_SIZE,
|
||
})
|
||
selectedTextIndex.value = annotations.value.length - 1
|
||
}
|
||
textDraft.value = null
|
||
}
|
||
|
||
function cancelTextDraft() {
|
||
textDraft.value = null
|
||
}
|
||
|
||
function onTextDraftKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
commitTextDraft()
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
cancelTextDraft()
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('keydown', onKeydown)
|
||
})
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', onKeydown)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="overlay-root"
|
||
:style="{ width: width + 'px', height: height + 'px' }"
|
||
@mousedown="onMouseDown"
|
||
@mousemove="onMouseMove"
|
||
@mouseup="onMouseUp"
|
||
@contextmenu.prevent="onCancel"
|
||
>
|
||
<svg
|
||
class="mask"
|
||
:width="width"
|
||
:height="height"
|
||
:viewBox="`0 0 ${width} ${height}`"
|
||
preserveAspectRatio="none"
|
||
>
|
||
<path :d="maskPath" fill="rgba(0,0,0,0.45)" fill-rule="evenodd" />
|
||
<rect
|
||
v-if="rect"
|
||
:x="rect.x"
|
||
:y="rect.y"
|
||
:width="rect.w"
|
||
:height="rect.h"
|
||
fill="transparent"
|
||
stroke="#3b82f6"
|
||
stroke-width="1.5"
|
||
vector-effect="non-scaling-stroke"
|
||
/>
|
||
<g v-for="(annotation, index) in selectionAnnotations" :key="index">
|
||
<polyline
|
||
v-if="annotation.tool === 'pen'"
|
||
:points="annotation.points.map((p) => `${p.x},${p.y}`).join(' ')"
|
||
:stroke="annotation.color"
|
||
stroke-width="3"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
fill="none"
|
||
/>
|
||
<rect
|
||
v-else-if="annotation.tool === 'rect' && annotation.points.length >= 2"
|
||
:x="Math.min(annotation.points[0].x, annotation.points[1].x)"
|
||
:y="Math.min(annotation.points[0].y, annotation.points[1].y)"
|
||
:width="Math.abs(annotation.points[1].x - annotation.points[0].x)"
|
||
:height="Math.abs(annotation.points[1].y - annotation.points[0].y)"
|
||
:stroke="annotation.color"
|
||
stroke-width="3"
|
||
fill="none"
|
||
/>
|
||
<ellipse
|
||
v-else-if="annotation.tool === 'ellipse' && annotation.points.length >= 2"
|
||
:cx="(annotation.points[0].x + annotation.points[1].x) / 2"
|
||
:cy="(annotation.points[0].y + annotation.points[1].y) / 2"
|
||
:rx="Math.abs(annotation.points[1].x - annotation.points[0].x) / 2"
|
||
:ry="Math.abs(annotation.points[1].y - annotation.points[0].y) / 2"
|
||
:stroke="annotation.color"
|
||
stroke-width="3"
|
||
fill="none"
|
||
/>
|
||
<template v-else-if="annotation.tool === 'text' && annotation.points.length >= 1">
|
||
<rect
|
||
v-if="selectedTextIndex === index && index < annotations.length"
|
||
class="text-selection-box"
|
||
:x="textRenderBox(annotation).x"
|
||
:y="textRenderBox(annotation).y"
|
||
:width="textRenderBox(annotation).w"
|
||
:height="textRenderBox(annotation).h"
|
||
/>
|
||
<text
|
||
class="annotation-text"
|
||
:x="annotation.points[0].x"
|
||
:y="annotation.points[0].y"
|
||
:fill="annotation.color"
|
||
:font-size="annotation.fontSize || TEXT_FONT_SIZE"
|
||
font-weight="600"
|
||
dominant-baseline="hanging"
|
||
>
|
||
{{ annotation.text }}
|
||
</text>
|
||
</template>
|
||
</g>
|
||
</svg>
|
||
|
||
<div
|
||
v-if="rect"
|
||
class="selection-hit-area"
|
||
:style="{
|
||
left: rect.x + 'px',
|
||
top: rect.y + 'px',
|
||
width: rect.w + 'px',
|
||
height: rect.h + 'px',
|
||
}"
|
||
@mousedown="onSelectionMouseDown"
|
||
/>
|
||
|
||
<input
|
||
v-if="rect && textDraft"
|
||
ref="textInputRef"
|
||
v-model="textDraft.value"
|
||
class="text-editor"
|
||
:style="{
|
||
left: rect.x + textDraft.point.x + 'px',
|
||
top: rect.y + textDraft.point.y + 'px',
|
||
color: textDraft.color,
|
||
}"
|
||
spellcheck="false"
|
||
@mousedown.stop
|
||
@keydown.stop="onTextDraftKeydown"
|
||
@blur="commitTextDraft"
|
||
/>
|
||
|
||
<div
|
||
v-for="handle in handles"
|
||
:key="handle.name"
|
||
class="resize-handle"
|
||
:class="`handle-${handle.name}`"
|
||
:style="{ left: handle.x + 'px', top: handle.y + 'px' }"
|
||
@mousedown.stop="
|
||
(event) => {
|
||
dragMode = 'resizing'
|
||
resizeHandle = handle.name
|
||
dragAnchor = pointFromEvent(event)
|
||
startRect = rect ? { ...rect } : null
|
||
}
|
||
"
|
||
/>
|
||
|
||
<div
|
||
v-if="rect && rect.w > 0 && rect.h > 0"
|
||
class="size-pill"
|
||
:style="{
|
||
left: rect.x + 'px',
|
||
top: Math.max(0, rect.y - 26) + 'px',
|
||
}"
|
||
>
|
||
{{ sizeLabel }}
|
||
</div>
|
||
|
||
<div
|
||
v-if="leftToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
|
||
class="toolbar mark-toolbar"
|
||
:style="{ left: leftToolbarPos.x + 'px', top: leftToolbarPos.y + 'px' }"
|
||
@mousedown.stop
|
||
>
|
||
<button
|
||
class="icon-btn"
|
||
:class="{ active: activeTool === 'pen' }"
|
||
title="划线标记"
|
||
@click="selectTool('pen')"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M4 20c4-1 6-3 8-7l5-9 3 2-5 9c-2 4-5 6-9 7z" />
|
||
<path d="M14 5l5 3" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="icon-btn"
|
||
:class="{ active: activeTool === 'rect' }"
|
||
title="矩形标记"
|
||
@click="selectTool('rect')"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect x="5" y="6" width="14" height="12" rx="1.5" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="icon-btn"
|
||
:class="{ active: activeTool === 'ellipse' }"
|
||
title="圆形标记"
|
||
@click="selectTool('ellipse')"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="7" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="icon-btn"
|
||
:class="{ active: activeTool === 'text' }"
|
||
title="文字标记"
|
||
@click="selectTool('text')"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M5 5h14" />
|
||
<path d="M12 5v14" />
|
||
<path d="M9 19h6" />
|
||
</svg>
|
||
</button>
|
||
<div class="color-wrap">
|
||
<button
|
||
class="icon-btn color-btn"
|
||
title="选择标记颜色"
|
||
@click="paletteOpen = !paletteOpen"
|
||
>
|
||
<span :style="{ background: activeColor }" />
|
||
</button>
|
||
<div v-if="paletteOpen" class="palette">
|
||
<button
|
||
v-for="color in colors"
|
||
:key="color"
|
||
class="swatch"
|
||
:class="{ selected: color === activeColor }"
|
||
:style="{ background: color }"
|
||
:title="color"
|
||
@click="chooseColor(color)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="icon-btn"
|
||
:disabled="annotations.length === 0"
|
||
title="撤销上一处标记"
|
||
@click="undoAnnotation"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M9 7H4v5" />
|
||
<path d="M4 7c3-3 8-4 12-1 4 3 4 9 0 12-2 1-4 2-7 1" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="rightToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
|
||
class="toolbar action-toolbar"
|
||
:style="{ left: rightToolbarPos.x + 'px', top: rightToolbarPos.y + 'px' }"
|
||
@mousedown.stop
|
||
>
|
||
<button
|
||
class="action-btn cancel"
|
||
data-tip="取消截图"
|
||
aria-label="取消截图"
|
||
@click="onCancel"
|
||
v-html="cancelIcon"
|
||
/>
|
||
<button
|
||
class="action-btn"
|
||
data-tip="复制图片"
|
||
aria-label="复制图片"
|
||
@click="onCopy"
|
||
v-html="copyIcon"
|
||
/>
|
||
<button
|
||
class="action-btn"
|
||
data-tip="保存本地"
|
||
aria-label="保存本地"
|
||
@click="onSave"
|
||
v-html="saveIcon"
|
||
/>
|
||
<button
|
||
class="action-btn"
|
||
data-tip="保存远端"
|
||
aria-label="保存远端"
|
||
@click="onSaveRemote"
|
||
v-html="saveRemoteIcon"
|
||
/>
|
||
<button
|
||
class="action-btn"
|
||
data-tip="复制总结"
|
||
aria-label="复制总结"
|
||
@click="onSummarize"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 3h8l4 4v14H6z" />
|
||
<path d="M14 3v5h4" />
|
||
<path d="M9 12h6" />
|
||
<path d="M9 16h5" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="action-btn primary"
|
||
data-tip="上传云端"
|
||
aria-label="上传云端"
|
||
@click="onConfirm"
|
||
v-html="uploadIcon"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="!rect" class="hint">
|
||
Drag to select an area · Esc to cancel
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.overlay-root {
|
||
position: fixed;
|
||
inset: 0;
|
||
user-select: none;
|
||
cursor: crosshair;
|
||
background: transparent;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mask {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.annotation-text {
|
||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||
}
|
||
|
||
.text-selection-box {
|
||
fill: transparent;
|
||
stroke: #3b82f6;
|
||
stroke-width: 1.5;
|
||
stroke-dasharray: 4 3;
|
||
}
|
||
|
||
.selection-hit-area {
|
||
position: absolute;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.text-editor {
|
||
position: absolute;
|
||
z-index: 6;
|
||
min-width: 120px;
|
||
max-width: 320px;
|
||
height: 28px;
|
||
padding: 2px 6px;
|
||
border: 1px solid currentColor;
|
||
border-radius: 4px;
|
||
outline: none;
|
||
background: rgba(15, 23, 42, 0.72);
|
||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||
font: 600 20px/1.2 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||
}
|
||
|
||
.resize-handle {
|
||
position: absolute;
|
||
width: 10px;
|
||
height: 10px;
|
||
transform: translate(-50%, -50%);
|
||
border: 1px solid #fff;
|
||
background: #3b82f6;
|
||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
|
||
z-index: 3;
|
||
}
|
||
.handle-n,
|
||
.handle-s {
|
||
cursor: ns-resize;
|
||
}
|
||
.handle-e,
|
||
.handle-w {
|
||
cursor: ew-resize;
|
||
}
|
||
.handle-nw,
|
||
.handle-se {
|
||
cursor: nwse-resize;
|
||
}
|
||
.handle-ne,
|
||
.handle-sw {
|
||
cursor: nesw-resize;
|
||
}
|
||
|
||
.size-pill {
|
||
position: absolute;
|
||
padding: 2px 8px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #fff;
|
||
background: rgba(15, 23, 42, 0.78);
|
||
border-radius: 4px;
|
||
pointer-events: none;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.toolbar {
|
||
position: absolute;
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
height: 32px;
|
||
padding: 4px;
|
||
background: rgba(28, 28, 32, 0.94);
|
||
border-radius: 8px;
|
||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4);
|
||
cursor: default;
|
||
z-index: 4;
|
||
}
|
||
|
||
.mark-toolbar {
|
||
width: 222px;
|
||
}
|
||
.action-toolbar {
|
||
width: 216px;
|
||
}
|
||
|
||
.icon-btn,
|
||
.action-btn,
|
||
.swatch {
|
||
font-family: inherit;
|
||
}
|
||
|
||
/*
|
||
* .action-btn — Square SVG icon button used in the bottom-right action toolbar.
|
||
*
|
||
* Design rationale:
|
||
* - Mirrors .icon-btn sizing (32px square) so the action toolbar is visually
|
||
* balanced with the annotation toolbar.
|
||
* - Uses `currentColor` so each instance can override its own hue (e.g. the
|
||
* primary upload button is blue) while sharing one base set of rules.
|
||
* - The native CSS tooltip is rendered through `::after` driven by the
|
||
* `data-tip` attribute. This avoids the system `title` tooltip's slow show
|
||
* delay and lets us style/position the bubble consistently.
|
||
*/
|
||
.action-btn {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 0;
|
||
border-radius: 5px;
|
||
color: #d1d5db;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.action-btn:hover {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
.action-btn.cancel:hover {
|
||
color: #fca5a5;
|
||
background: rgba(239, 68, 68, 0.18);
|
||
}
|
||
.action-btn.primary {
|
||
color: #60a5fa;
|
||
}
|
||
.action-btn.primary:hover {
|
||
color: #fff;
|
||
background: #2563eb;
|
||
}
|
||
.action-btn :deep(svg) {
|
||
width: 18px;
|
||
height: 18px;
|
||
display: block;
|
||
fill: currentColor;
|
||
pointer-events: none;
|
||
}
|
||
.action-btn > svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
pointer-events: none;
|
||
}
|
||
.action-btn::after {
|
||
content: attr(data-tip);
|
||
position: absolute;
|
||
bottom: calc(100% + 6px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: 3px 8px;
|
||
font-size: 12px;
|
||
color: #fff;
|
||
background: rgba(15, 23, 42, 0.92);
|
||
border-radius: 4px;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.12s ease 0.05s;
|
||
z-index: 5;
|
||
}
|
||
.action-btn:hover::after {
|
||
opacity: 1;
|
||
}
|
||
|
||
.icon-btn {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 0;
|
||
border-radius: 5px;
|
||
color: #d1d5db;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
.icon-btn:hover:not(:disabled),
|
||
.icon-btn.active {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
.icon-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
.icon-btn svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.color-wrap {
|
||
position: relative;
|
||
}
|
||
.color-btn span {
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
|
||
}
|
||
.palette {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 36px;
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 22px);
|
||
gap: 6px;
|
||
padding: 8px;
|
||
background: rgba(28, 28, 32, 0.96);
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.45);
|
||
}
|
||
.swatch {
|
||
width: 22px;
|
||
height: 22px;
|
||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.swatch.selected {
|
||
outline: 2px solid #fff;
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.hint {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 24px;
|
||
transform: translateX(-50%);
|
||
padding: 6px 14px;
|
||
font-size: 12px;
|
||
color: #f3f4f6;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
border-radius: 999px;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|