feat: add annotation tool settings menus

This commit is contained in:
2026-07-08 16:20:51 +08:00
parent 7b290d9300
commit 18a9f53062
6 changed files with 587 additions and 149 deletions
+1
View File
@@ -82,6 +82,7 @@ interface OverlayAnnotation {
color: string
points: Array<{ x: number; y: number }>
text?: string
strokeWidth?: number
fontSize?: number
}
+233 -68
View File
@@ -33,6 +33,7 @@ interface Annotation {
color: string
points: Point[]
text?: string
strokeWidth?: number
fontSize?: number
}
@@ -65,11 +66,13 @@ const annotations = ref<Annotation[]>([])
const draftAnnotation = ref<Annotation | null>(null)
const activeTool = ref<Tool>('pen')
const activeColor = ref('#ef4444')
const paletteOpen = ref(false)
const activeStrokeWidth = ref(3)
const activeFontSize = ref(20)
const textDraft = ref<{
point: Point
value: string
color: string
fontSize: number
index: number | null
} | null>(null)
const textInputRef = ref<HTMLInputElement | null>(null)
@@ -90,19 +93,18 @@ const startRect = ref<Rect | null>(null)
const textDragStartPoint = ref<Point | null>(null)
const textDragOriginalPoint = ref<Point | null>(null)
const TEXT_FONT_SIZE = 20
const DEFAULT_TEXT_FONT_SIZE = 20
const STROKE_WIDTHS = [2, 4, 6]
const FONT_SIZES = [16, 20, 28, 36]
const colors = [
'#ef4444',
'#f97316',
'#facc15',
'#22c55e',
'#06b6d4',
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#ffffff',
'#111827',
'#9ca3af',
'#ffffff',
]
const maskPath = computed(() => {
@@ -135,10 +137,32 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const MARK_TOOLBAR_W = 222
const MARK_TOOLBAR_W = 178
const ACTION_TOOLBAR_W = 216
const TOOLBAR_GROUP_GAP = 8
const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text']
const toolSettingsPos = computed(() => {
if (!leftToolbarPos.value || !rect.value || !activeTool.value) return null
const menuW = activeTool.value === 'text' ? 462 : 398
const menuH = 58
const buttonIndex = toolOrder.indexOf(activeTool.value)
const buttonCenter = 4 + buttonIndex * 34 + 14
let x = leftToolbarPos.value.x
let y = leftToolbarPos.value.y + 44
if (y + menuH > props.height) {
y = leftToolbarPos.value.y - menuH - 8
}
x = clamp(x, 8, Math.max(8, props.width - menuW - 8))
return {
x,
y,
w: menuW,
arrowX: clamp(leftToolbarPos.value.x + buttonCenter - x, 18, menuW - 18),
}
})
const rightToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right')
@@ -226,12 +250,12 @@ function insideRect(p: Point, r: Rect | null) {
let measureCanvas: HTMLCanvasElement | null = null
function textFont(fontSize = TEXT_FONT_SIZE) {
function textFont(fontSize = DEFAULT_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 fontSize = annotation.fontSize || DEFAULT_TEXT_FONT_SIZE
const lines = (annotation.text || ' ').split('\n')
if (typeof document === 'undefined') {
return {
@@ -254,6 +278,18 @@ function textSize(annotation: Pick<Annotation, 'text' | 'fontSize'>) {
}
}
function textEditorSize(draft: { value: string; fontSize: number }) {
const size = textSize({
text: draft.value || ' ',
fontSize: draft.fontSize,
})
return {
width: Math.max(180, Math.min(420, size.w + 28)),
height: Math.max(30, Math.ceil(draft.fontSize * 1.6)),
lineHeight: Math.ceil(draft.fontSize * 1.25),
}
}
function textRenderBox(annotation: Annotation) {
const point = annotation.points[0] || { x: 0, y: 0 }
const size = textSize(annotation)
@@ -319,7 +355,6 @@ function hitHandle(p: Point): ResizeHandle | null {
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
commitTextDraft()
paletteOpen.value = false
textDragStartPoint.value = null
textDragOriginalPoint.value = null
const p = pointFromEvent(e)
@@ -348,12 +383,14 @@ 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]
activeTool.value = 'text'
activeColor.value = annotation.color
activeFontSize.value = annotation.fontSize || DEFAULT_TEXT_FONT_SIZE
if (e.detail >= 2) {
beginTextAnnotation(annotation.points[0], hitTextIndex)
return
@@ -386,6 +423,7 @@ function onSelectionMouseDown(e: MouseEvent) {
draftAnnotation.value = {
tool: activeTool.value,
color: activeColor.value,
strokeWidth: activeStrokeWidth.value,
points: [local],
}
}
@@ -479,7 +517,38 @@ function selectTool(tool: Tool) {
function chooseColor(color: string) {
activeColor.value = color
paletteOpen.value = false
if (textDraft.value) {
textDraft.value.color = color
} else if (
activeTool.value === 'text' &&
selectedTextIndex.value !== null &&
annotations.value[selectedTextIndex.value]?.tool === 'text'
) {
annotations.value[selectedTextIndex.value].color = color
}
}
function chooseStrokeWidth(width: number) {
activeStrokeWidth.value = width
}
function chooseFontSize(size: number) {
activeFontSize.value = size
if (textDraft.value) {
textDraft.value.fontSize = size
textDraft.value.point = clampTextLocalPoint(textDraft.value.point, {
text: textDraft.value.value,
fontSize: size,
})
} else if (
activeTool.value === 'text' &&
selectedTextIndex.value !== null &&
annotations.value[selectedTextIndex.value]?.tool === 'text'
) {
const annotation = annotations.value[selectedTextIndex.value]
annotation.fontSize = size
annotation.points[0] = clampTextLocalPoint(annotation.points[0], annotation)
}
}
function onConfirm() {
@@ -538,7 +607,9 @@ function onKeydown(e: KeyboardEvent) {
}
function undoAnnotation() {
if (!textDraft.value && annotations.value.length === 0) return
cancelTextDraft()
if (annotations.value.length === 0) return
annotations.value.pop()
if (
selectedTextIndex.value !== null &&
@@ -561,10 +632,16 @@ function beginTextAnnotation(point: Point, index: number | null = null) {
draftAnnotation.value = null
const existing = index !== null ? annotations.value[index] : null
const color = existing?.color || activeColor.value
const fontSize = existing?.fontSize || activeFontSize.value
if (existing) {
activeColor.value = color
activeFontSize.value = fontSize
}
textDraft.value = {
point: clampTextLocalPoint(point, existing || { text: '', fontSize: TEXT_FONT_SIZE }),
point: clampTextLocalPoint(point, existing || { text: '', fontSize }),
value: existing?.text || '',
color,
fontSize,
index,
}
void nextTick(() => {
@@ -581,10 +658,10 @@ function commitTextDraft() {
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.points = [clampTextLocalPoint(draft.point, { text, fontSize: draft.fontSize })]
target.text = text
target.color = draft.color
target.fontSize = target.fontSize || TEXT_FONT_SIZE
target.fontSize = draft.fontSize
selectedTextIndex.value = draft.index
} else {
annotations.value.splice(draft.index, 1)
@@ -592,13 +669,13 @@ function commitTextDraft() {
}
}
} else if (text !== '') {
const point = clampTextLocalPoint(draft.point, { text, fontSize: TEXT_FONT_SIZE })
const point = clampTextLocalPoint(draft.point, { text, fontSize: draft.fontSize })
annotations.value.push({
tool: 'text',
color: draft.color,
points: [point],
text,
fontSize: TEXT_FONT_SIZE,
fontSize: draft.fontSize,
})
selectedTextIndex.value = annotations.value.length - 1
}
@@ -660,7 +737,7 @@ onUnmounted(() => {
v-if="annotation.tool === 'pen'"
:points="annotation.points.map((p) => `${p.x},${p.y}`).join(' ')"
:stroke="annotation.color"
stroke-width="3"
:stroke-width="annotation.strokeWidth || 3"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
@@ -672,7 +749,7 @@ onUnmounted(() => {
: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"
:stroke-width="annotation.strokeWidth || 3"
fill="none"
/>
<ellipse
@@ -682,7 +759,7 @@ onUnmounted(() => {
: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"
:stroke-width="annotation.strokeWidth || 3"
fill="none"
/>
<template v-else-if="annotation.tool === 'text' && annotation.points.length >= 1">
@@ -699,7 +776,7 @@ onUnmounted(() => {
:x="annotation.points[0].x"
:y="annotation.points[0].y"
:fill="annotation.color"
:font-size="annotation.fontSize || TEXT_FONT_SIZE"
:font-size="annotation.fontSize || DEFAULT_TEXT_FONT_SIZE"
font-weight="600"
dominant-baseline="hanging"
>
@@ -730,6 +807,10 @@ onUnmounted(() => {
left: rect.x + textDraft.point.x + 'px',
top: rect.y + textDraft.point.y + 'px',
color: textDraft.color,
fontSize: textDraft.fontSize + 'px',
width: textEditorSize(textDraft).width + 'px',
height: textEditorSize(textDraft).height + 'px',
lineHeight: textEditorSize(textDraft).lineHeight + 'px',
}"
spellcheck="false"
@mousedown.stop
@@ -813,29 +894,10 @@ onUnmounted(() => {
<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"
:class="{ muted: annotations.length === 0 }"
:aria-disabled="annotations.length === 0"
title="撤销上一处标记"
@click="undoAnnotation"
>
@@ -846,6 +908,55 @@ onUnmounted(() => {
</button>
</div>
<div
v-if="toolSettingsPos && rect && rect.w >= 4 && rect.h >= 4"
class="tool-settings-menu"
:style="{
left: toolSettingsPos.x + 'px',
top: toolSettingsPos.y + 'px',
width: toolSettingsPos.w + 'px',
'--arrow-x': toolSettingsPos.arrowX + 'px',
}"
@mousedown.stop
>
<div v-if="activeTool !== 'text'" class="setting-group stroke-group">
<button
v-for="widthValue in STROKE_WIDTHS"
:key="widthValue"
class="stroke-choice"
:class="{ active: activeStrokeWidth === widthValue }"
:title="`${widthValue}px`"
@click="chooseStrokeWidth(widthValue)"
>
<span :style="{ width: widthValue * 4 + 'px', height: widthValue * 4 + 'px' }" />
</button>
</div>
<div v-else class="setting-group font-group">
<button
v-for="size in FONT_SIZES"
:key="size"
class="font-choice"
:class="{ active: activeFontSize === size }"
:title="`${size}px`"
@click="chooseFontSize(size)"
>
{{ size }}
</button>
</div>
<div class="settings-divider" />
<div class="setting-group color-group">
<button
v-for="color in colors"
:key="color"
class="swatch"
:class="{ selected: color === activeColor }"
:style="{ background: color }"
:title="color"
@click="chooseColor(color)"
/>
</div>
</div>
<div
v-if="rightToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
class="toolbar action-toolbar"
@@ -944,10 +1055,10 @@ onUnmounted(() => {
.text-editor {
position: absolute;
z-index: 6;
box-sizing: border-box;
min-width: 120px;
max-width: 320px;
height: 28px;
padding: 2px 6px;
max-width: 420px;
padding: 0 10px;
border: 1px solid currentColor;
border-radius: 4px;
outline: none;
@@ -1010,7 +1121,7 @@ onUnmounted(() => {
}
.mark-toolbar {
width: 222px;
width: 178px;
}
.action-toolbar {
width: 216px;
@@ -1018,7 +1129,9 @@ onUnmounted(() => {
.icon-btn,
.action-btn,
.swatch {
.swatch,
.stroke-choice,
.font-choice {
font-family: inherit;
}
@@ -1111,14 +1224,14 @@ onUnmounted(() => {
background: transparent;
cursor: pointer;
}
.icon-btn:hover:not(:disabled),
.icon-btn:hover:not(.muted),
.icon-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.icon-btn:disabled {
.icon-btn.muted {
opacity: 0.35;
cursor: not-allowed;
cursor: default;
}
.icon-btn svg {
width: 18px;
@@ -1130,36 +1243,88 @@ onUnmounted(() => {
stroke-linejoin: round;
}
.color-wrap {
position: relative;
.tool-settings-menu {
position: absolute;
z-index: 5;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
min-height: 42px;
padding: 8px 14px;
color: #1f2937;
background: rgba(255, 255, 255, 0.96);
border-radius: 8px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.26);
cursor: default;
}
.color-btn span {
.tool-settings-menu::before {
content: '';
position: absolute;
left: var(--arrow-x);
top: -8px;
transform: translateX(-50%) rotate(45deg);
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);
background: rgba(255, 255, 255, 0.96);
border-radius: 3px;
}
.palette {
position: absolute;
left: 0;
bottom: 36px;
.setting-group {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
}
.stroke-choice,
.font-choice {
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);
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 6px;
color: #1f2937;
background: transparent;
cursor: pointer;
}
.stroke-choice:hover,
.stroke-choice.active,
.font-choice:hover,
.font-choice.active {
color: #2563eb;
background: #eaf1ff;
}
.stroke-choice span {
display: block;
border-radius: 999px;
background: currentColor;
}
.font-choice {
min-width: 40px;
width: auto;
padding: 0 8px;
font-size: 14px;
font-weight: 700;
}
.settings-divider {
position: relative;
z-index: 1;
width: 1px;
height: 26px;
background: #d1d5db;
}
.swatch {
width: 22px;
height: 22px;
border: 1px solid rgba(255, 255, 255, 0.55);
padding: 0;
border: 1px solid rgba(17, 24, 39, 0.18);
border-radius: 4px;
cursor: pointer;
}
.swatch.selected {
outline: 2px solid #fff;
outline: 2px solid #2563eb;
outline-offset: 2px;
}