feat: add mosaic screenshot annotations

This commit is contained in:
2026-07-12 20:40:05 +08:00
parent dd12521be2
commit b82108db38
4 changed files with 535 additions and 21 deletions
+167 -12
View File
@@ -24,7 +24,8 @@ interface Rect {
h: number
}
type Tool = 'pen' | 'rect' | 'ellipse' | 'text'
type Tool = 'pen' | 'rect' | 'ellipse' | 'text' | 'mosaic-brush' | 'mosaic-rect'
type MosaicMode = 'brush' | 'rect'
interface Point {
x: number
y: number
@@ -77,6 +78,8 @@ const activeTool = ref<Tool>('pen')
const activeColor = ref('#ef4444')
const activeStrokeWidth = ref(3)
const activeFontSize = ref(20)
const mosaicMode = ref<MosaicMode>('brush')
const mosaicBrushWidth = ref(24)
const textDraft = ref<{
point: Point
value: string
@@ -104,6 +107,7 @@ const textDragOriginalPoint = ref<Point | null>(null)
const DEFAULT_TEXT_FONT_SIZE = 20
const STROKE_WIDTHS = [2, 4, 6]
const MOSAIC_WIDTHS = [16, 24, 36, 52]
const FONT_SIZES = [16, 20, 28, 36]
const colors = [
@@ -146,17 +150,18 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const MARK_TOOLBAR_W = 178
const MARK_TOOLBAR_W = 212
const ACTION_TOOLBAR_W = 288
const TOOLBAR_GROUP_GAP = 8
const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text']
const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text', 'mosaic-brush', 'mosaic-rect']
const toolSettingsPos = computed(() => {
if (!leftToolbarPos.value || !rect.value || !activeTool.value) return null
const menuW = activeTool.value === 'text' ? 462 : 398
const isMosaic = activeTool.value.startsWith('mosaic-')
const menuW = isMosaic ? (mosaicMode.value === 'brush' ? 328 : 112) : activeTool.value === 'text' ? 462 : 398
const menuH = 58
const buttonIndex = toolOrder.indexOf(activeTool.value)
const buttonIndex = isMosaic ? 4 : toolOrder.indexOf(activeTool.value)
const buttonCenter = 4 + buttonIndex * 34 + 14
let x = leftToolbarPos.value.x
let y = leftToolbarPos.value.y + 44
@@ -432,7 +437,7 @@ function onSelectionMouseDown(e: MouseEvent) {
draftAnnotation.value = {
tool: activeTool.value,
color: activeColor.value,
strokeWidth: activeStrokeWidth.value,
strokeWidth: activeTool.value === 'mosaic-brush' ? mosaicBrushWidth.value : activeStrokeWidth.value,
points: [local],
}
}
@@ -466,7 +471,7 @@ function onMouseMove(e: MouseEvent) {
annotation.points[0] = clampTextLocalPoint(next, annotation)
}
} else if (dragMode.value === 'annotating' && draftAnnotation.value) {
if (activeTool.value === 'pen') {
if (activeTool.value === 'pen' || activeTool.value === 'mosaic-brush') {
draftAnnotation.value.points.push(localPoint(p))
} else {
draftAnnotation.value.points = [
@@ -524,6 +529,19 @@ function selectTool(tool: Tool) {
activeTool.value = tool
}
function selectMosaic() {
activeTool.value = mosaicMode.value === 'brush' ? 'mosaic-brush' : 'mosaic-rect'
}
function chooseMosaicMode(mode: MosaicMode) {
mosaicMode.value = mode
selectMosaic()
}
function chooseMosaicWidth(width: number) {
mosaicBrushWidth.value = width
}
function chooseColor(color: string) {
activeColor.value = color
if (textDraft.value) {
@@ -748,6 +766,13 @@ onUnmounted(() => {
:viewBox="`0 0 ${width} ${height}`"
preserveAspectRatio="none"
>
<defs>
<pattern id="mosaic-preview" width="10" height="10" patternUnits="userSpaceOnUse">
<rect width="10" height="10" fill="rgba(71,85,105,.82)" />
<rect width="5" height="5" fill="rgba(203,213,225,.82)" />
<rect x="5" y="5" width="5" height="5" fill="rgba(148,163,184,.82)" />
</pattern>
</defs>
<path :d="maskPath" fill="rgba(0,0,0,0.45)" fill-rule="evenodd" />
<rect
v-if="rect"
@@ -811,6 +836,25 @@ onUnmounted(() => {
{{ annotation.text }}
</text>
</template>
<polyline
v-else-if="annotation.tool === 'mosaic-brush'"
:points="annotation.points.map((p) => `${p.x},${p.y}`).join(' ')"
stroke="url(#mosaic-preview)"
:stroke-width="annotation.strokeWidth || 24"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<rect
v-else-if="annotation.tool === 'mosaic-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)"
fill="url(#mosaic-preview)"
stroke="rgba(255,255,255,.8)"
stroke-width="1"
/>
</g>
</svg>
@@ -922,6 +966,21 @@ onUnmounted(() => {
<path d="M9 19h6" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool.startsWith('mosaic-') }"
title="马赛克标记"
aria-label="马赛克标记"
:aria-expanded="activeTool.startsWith('mosaic-')"
@click="selectMosaic"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="6" height="6" />
<rect x="14" y="4" width="6" height="6" />
<rect x="4" y="14" width="6" height="6" />
<rect x="14" y="14" width="6" height="6" />
</svg>
</button>
<button
class="icon-btn"
:class="{ muted: annotations.length === 0 }"
@@ -947,7 +1006,55 @@ onUnmounted(() => {
}"
@mousedown.stop
>
<div v-if="activeTool !== 'text'" class="setting-group stroke-group">
<template v-if="activeTool.startsWith('mosaic-')">
<div class="setting-group mosaic-mode-group" role="group" aria-label="马赛克标记方式">
<button
class="mode-choice"
:class="{ active: mosaicMode === 'brush' }"
title="涂抹"
aria-label="涂抹马赛克"
:aria-pressed="mosaicMode === 'brush'"
@click="chooseMosaicMode('brush')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 11V5.5a2 2 0 0 1 4 0V10" />
<path d="M13 10V8a2 2 0 0 1 4 0v3" />
<path d="M17 10a2 2 0 0 1 4 0v4.5c0 4-2.5 6.5-6.5 6.5h-1.2a6 6 0 0 1-4.7-2.3L4 13.2a1.9 1.9 0 0 1 2.8-2.5L9 12.5" />
</svg>
</button>
<button
class="mode-choice"
:class="{ active: mosaicMode === 'rect' }"
title="框选"
aria-label="框选马赛克"
:aria-pressed="mosaicMode === 'rect'"
@click="chooseMosaicMode('rect')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="2.5" y="2.5" width="19" height="19" rx="2" />
<rect class="pixel-block" x="6" y="6" width="4" height="4" rx=".5" />
<rect class="pixel-block" x="14" y="6" width="4" height="4" rx=".5" />
<rect class="pixel-block" x="6" y="14" width="4" height="4" rx=".5" />
<rect class="pixel-block" x="14" y="14" width="4" height="4" rx=".5" />
</svg>
</button>
</div>
<template v-if="mosaicMode === 'brush'">
<div class="settings-divider" />
<span class="setting-label">粗细</span>
<div class="setting-group stroke-group">
<button
v-for="widthValue in MOSAIC_WIDTHS"
:key="widthValue"
class="stroke-choice"
:class="{ active: mosaicBrushWidth === widthValue }"
:title="`${widthValue}px`"
@click="chooseMosaicWidth(widthValue)"
><span :style="{ width: Math.min(22, widthValue / 2) + 'px', height: Math.min(22, widthValue / 2) + 'px' }" /></button>
</div>
</template>
</template>
<div v-else-if="activeTool !== 'text'" class="setting-group stroke-group">
<button
v-for="widthValue in STROKE_WIDTHS"
:key="widthValue"
@@ -971,8 +1078,8 @@ onUnmounted(() => {
{{ size }}
</button>
</div>
<div class="settings-divider" />
<div class="setting-group color-group">
<div v-if="!activeTool.startsWith('mosaic-')" class="settings-divider" />
<div v-if="!activeTool.startsWith('mosaic-')" class="setting-group color-group">
<button
v-for="color in colors"
:key="color"
@@ -1171,7 +1278,7 @@ onUnmounted(() => {
}
.mark-toolbar {
width: 178px;
width: 212px;
}
.action-toolbar {
box-sizing: border-box;
@@ -1183,7 +1290,8 @@ onUnmounted(() => {
.action-btn,
.swatch,
.stroke-choice,
.font-choice {
.font-choice,
.mode-choice {
font-family: inherit;
}
@@ -1341,6 +1449,53 @@ onUnmounted(() => {
background: transparent;
cursor: pointer;
}
.mosaic-mode-group {
gap: 0;
padding: 2px;
border-radius: 7px;
background: #e5e7eb;
}
.mode-choice {
display: grid;
place-items: center;
width: 36px;
min-width: 36px;
height: 30px;
padding: 0;
border: 0;
border-radius: 5px;
color: #475569;
background: transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.mode-choice svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.mode-choice svg .pixel-block {
fill: currentColor;
stroke: none;
}
.mode-choice:hover,
.mode-choice.active {
color: #1d4ed8;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.14);
}
.setting-label {
position: relative;
z-index: 1;
color: #64748b;
font-size: 12px;
white-space: nowrap;
}
.stroke-choice:hover,
.stroke-choice.active,
.font-choice:hover,