Files
SnapGo/frontend/src/views/CaptureOverlay.vue
T

1179 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 &nbsp;·&nbsp; 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>