feat: add annotation tool settings menus
This commit is contained in:
@@ -82,6 +82,7 @@ interface OverlayAnnotation {
|
||||
color: string
|
||||
points: Array<{ x: number; y: number }>
|
||||
text?: string
|
||||
strokeWidth?: number
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export namespace application {
|
||||
color: string;
|
||||
points: Point[];
|
||||
text?: string;
|
||||
strokeWidth?: number;
|
||||
fontSize?: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -31,6 +32,7 @@ export namespace application {
|
||||
this.color = source["color"];
|
||||
this.points = this.convertValues(source["points"], Point);
|
||||
this.text = source["text"];
|
||||
this.strokeWidth = source["strokeWidth"];
|
||||
this.fontSize = source["fontSize"];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user