feat: add text annotation tool
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@ export namespace application {
|
||||
tool: string;
|
||||
color: string;
|
||||
points: Point[];
|
||||
text?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Annotation(source);
|
||||
@@ -28,6 +29,7 @@ export namespace application {
|
||||
this.tool = source["tool"];
|
||||
this.color = source["color"];
|
||||
this.points = this.convertValues(source["points"], Point);
|
||||
this.text = source["text"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
|
||||
Reference in New Issue
Block a user