feat: add text annotation tool

This commit is contained in:
2026-07-07 23:49:24 +08:00
parent a15f5b2b78
commit f90612976f
6 changed files with 396 additions and 16 deletions
+114 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
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
@@ -23,7 +23,7 @@ interface Rect {
h: number
}
type Tool = 'pen' | 'rect' | 'ellipse'
type Tool = 'pen' | 'rect' | 'ellipse' | 'text'
interface Point {
x: number
y: number
@@ -32,6 +32,7 @@ interface Annotation {
tool: Tool
color: string
points: Point[]
text?: string
}
const emit = defineEmits<{
@@ -64,6 +65,8 @@ 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 } | null>(null)
const textInputRef = ref<HTMLInputElement | null>(null)
type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se'
type DragMode = 'idle' | 'creating' | 'moving' | 'resizing' | 'annotating'
@@ -115,7 +118,7 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const MARK_TOOLBAR_W = 190
const MARK_TOOLBAR_W = 222
const ACTION_TOOLBAR_W = 216
const TOOLBAR_GROUP_GAP = 8
@@ -227,6 +230,7 @@ function hitHandle(p: Point): ResizeHandle | null {
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
commitTextDraft()
paletteOpen.value = false
const p = pointFromEvent(e)
const handle = hitHandle(p)
@@ -253,6 +257,10 @@ function onSelectionMouseDown(e: MouseEvent) {
if (e.button !== 0 || !rect.value) return
e.stopPropagation()
paletteOpen.value = false
if (activeTool.value === 'text') {
beginTextAnnotation(localPoint(pointFromEvent(e)))
return
}
if (e.detail >= 2) {
removeSinglePointAnnotation()
onCopy()
@@ -373,6 +381,7 @@ function onSummarize() {
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
if (!rect.value) return
commitTextDraft()
const payload = {
rect: { ...rect.value },
annotations: annotations.value,
@@ -389,6 +398,13 @@ function onCancel() {
}
function onKeydown(e: KeyboardEvent) {
if (textDraft.value) {
if (e.key === 'Escape') {
e.preventDefault()
cancelTextDraft()
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
@@ -399,6 +415,7 @@ function onKeydown(e: KeyboardEvent) {
}
function undoAnnotation() {
cancelTextDraft()
annotations.value.pop()
}
@@ -409,6 +426,45 @@ function removeSinglePointAnnotation() {
}
}
function beginTextAnnotation(point: Point) {
commitTextDraft()
dragMode.value = 'idle'
draftAnnotation.value = null
textDraft.value = { point, value: '' }
void nextTick(() => {
textInputRef.value?.focus()
textInputRef.value?.select()
})
}
function commitTextDraft() {
if (!textDraft.value) return
const text = textDraft.value.value.trim()
if (text !== '') {
annotations.value.push({
tool: 'text',
color: activeColor.value,
points: [textDraft.value.point],
text,
})
}
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)
})
@@ -475,6 +531,17 @@ onUnmounted(() => {
stroke-width="3"
fill="none"
/>
<text
v-else-if="annotation.tool === 'text' && annotation.points.length >= 1"
:x="annotation.points[0].x"
:y="annotation.points[0].y"
:fill="annotation.color"
font-size="20"
font-weight="600"
dominant-baseline="hanging"
>
{{ annotation.text }}
</text>
</g>
</svg>
@@ -490,6 +557,22 @@ onUnmounted(() => {
@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: activeColor,
}"
spellcheck="false"
@mousedown.stop
@keydown.stop="onTextDraftKeydown"
@blur="commitTextDraft"
/>
<div
v-for="handle in handles"
:key="handle.name"
@@ -554,6 +637,18 @@ onUnmounted(() => {
<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"
@@ -671,6 +766,21 @@ onUnmounted(() => {
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;
@@ -725,7 +835,7 @@ onUnmounted(() => {
}
.mark-toolbar {
width: 190px;
width: 222px;
}
.action-toolbar {
width: 216px;