@zzalai/leafer-point-annotation 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +493 -250
- package/README_EN.md +498 -250
- package/docs/assets/index-BcqmlFff.js +1 -0
- package/docs/assets/{index-Dqqq7qvI.css → index-dq8tjOSG.css} +1 -1
- package/docs/index.html +2 -2
- package/package.json +2 -1
- package/project-docs/ARCHITECTURE.md +345 -354
- package/project-docs/REQUIREMENTS.md +232 -500
- package/skills/project-context.md +402 -0
- package/src/App.vue +502 -17
- package/src/components/PointAnnotation.vue +639 -219
- package/src/elements/PointAnnotationElement.ts +115 -17
- package/src/types/index.ts +22 -5
- package/src/utils/CanvasBrush.ts +20 -0
- package/docs/assets/index-CPn8AE3g.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
3
|
class="point-annotation"
|
|
4
|
-
:class="{ 'has-image':
|
|
4
|
+
:class="{ 'has-image': hasImage && showToolbar }"
|
|
5
5
|
@focus="isCanvasFocused = true"
|
|
6
6
|
@blur="isCanvasFocused = false"
|
|
7
7
|
@mouseenter="isMouseOverCanvas = true"
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
</div>
|
|
64
64
|
|
|
65
65
|
<!-- 缩放控制器 - 只在有图片时显示 -->
|
|
66
|
-
<div v-if="
|
|
66
|
+
<div v-if="showZoomController" class="zoom-controller">
|
|
67
67
|
<button class="zoom-button" title="缩小 (Ctrl+-)" @click="zoomOut">
|
|
68
68
|
<svg
|
|
69
69
|
xmlns="http://www.w3.org/2000/svg"
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
</div>
|
|
110
110
|
|
|
111
111
|
<!-- 工具栏 - 只在有图片时显示 -->
|
|
112
|
-
<div v-if="
|
|
112
|
+
<div v-if="showToolbar" class="toolbar">
|
|
113
113
|
<button
|
|
114
114
|
class="tool-button"
|
|
115
115
|
:class="{ active: currentTool === 'select' }"
|
|
@@ -156,62 +156,52 @@
|
|
|
156
156
|
</svg>
|
|
157
157
|
<span class="hotkey-hint" v-if="showHotkeys">P</span>
|
|
158
158
|
</button>
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<svg
|
|
167
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
168
|
-
width="20"
|
|
169
|
-
height="20"
|
|
170
|
-
viewBox="0 0 24 24"
|
|
171
|
-
fill="none"
|
|
172
|
-
stroke="currentColor"
|
|
173
|
-
stroke-width="2"
|
|
174
|
-
stroke-linecap="round"
|
|
175
|
-
stroke-linejoin="round"
|
|
159
|
+
<template v-if="effectiveEnableBrush">
|
|
160
|
+
<button
|
|
161
|
+
class="tool-button"
|
|
162
|
+
:class="{ active: currentTool === 'brush' }"
|
|
163
|
+
title="笔刷工具 (B)"
|
|
164
|
+
@click="brushTool()"
|
|
165
|
+
ref="brushButtonRef"
|
|
176
166
|
>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
167
|
+
<svg
|
|
168
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
169
|
+
width="20"
|
|
170
|
+
height="20"
|
|
171
|
+
viewBox="0 0 24 24"
|
|
172
|
+
fill="none"
|
|
173
|
+
stroke="currentColor"
|
|
174
|
+
stroke-width="2"
|
|
175
|
+
stroke-linecap="round"
|
|
176
|
+
stroke-linejoin="round"
|
|
177
|
+
>
|
|
178
|
+
<path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"></path>
|
|
179
|
+
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"></path>
|
|
180
|
+
</svg>
|
|
181
|
+
<span class="hotkey-hint" v-if="showHotkeys">B</span>
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
class="tool-button"
|
|
185
|
+
:class="{ active: currentTool === 'eraser' }"
|
|
186
|
+
title="橡皮擦工具 (E)"
|
|
187
|
+
@click="eraserTool"
|
|
198
188
|
>
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
</
|
|
189
|
+
<svg
|
|
190
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
191
|
+
width="20"
|
|
192
|
+
height="20"
|
|
193
|
+
viewBox="0 0 24 24"
|
|
194
|
+
fill="none"
|
|
195
|
+
stroke="currentColor"
|
|
196
|
+
stroke-width="2"
|
|
197
|
+
stroke-linecap="round"
|
|
198
|
+
stroke-linejoin="round"
|
|
199
|
+
>
|
|
200
|
+
<path d="M20 20H7L3 16C2.4 15.4 2.4 14.4 3 13.8L13.8 3C14.4 2.4 15.4 2.4 16 3L21 8C21.6 8.6 21.6 9.6 21 10.2L10.2 21C9.6 21.6 8.6 21.6 8 21L3 16"></path>
|
|
201
|
+
</svg>
|
|
202
|
+
<span class="hotkey-hint" v-if="showHotkeys">E</span>
|
|
203
|
+
</button>
|
|
204
|
+
</template>
|
|
215
205
|
<button class="tool-button" title="撤销 (Ctrl+Z)" @click="undo">
|
|
216
206
|
<svg
|
|
217
207
|
xmlns="http://www.w3.org/2000/svg"
|
|
@@ -267,8 +257,9 @@
|
|
|
267
257
|
</div>
|
|
268
258
|
</div>
|
|
269
259
|
|
|
270
|
-
<!--
|
|
260
|
+
<!-- 笔刷样式配置面板(仅在 enableBrush=true 时渲染) -->
|
|
271
261
|
<BrushStylePanel
|
|
262
|
+
v-if="effectiveEnableBrush"
|
|
272
263
|
:visible="showBrushPanel"
|
|
273
264
|
:brush-style="localBrushStyle"
|
|
274
265
|
:button-rect="brushButtonRect"
|
|
@@ -304,7 +295,7 @@ import { tinykeys } from "tinykeys";
|
|
|
304
295
|
import { PointAnnotationElement } from "@/elements/PointAnnotationElement";
|
|
305
296
|
import { CanvasBrush } from "@/utils/CanvasBrush";
|
|
306
297
|
import BrushStylePanel from "./BrushStylePanel.vue";
|
|
307
|
-
import type { PointAnnotation, PointStyle, BrushStyle } from "@/types";
|
|
298
|
+
import type { PointAnnotation, PointStyle, BrushStyle, BrushLayerConfig } from "@/types";
|
|
308
299
|
import { DEFAULT_POINT_STYLE, DEFAULT_BRUSH_STYLE } from "@/types";
|
|
309
300
|
|
|
310
301
|
// Props
|
|
@@ -314,23 +305,20 @@ export interface ImageSource {
|
|
|
314
305
|
}
|
|
315
306
|
|
|
316
307
|
export interface OptionsSource {
|
|
317
|
-
pointStyle?:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
width: number;
|
|
322
|
-
height: number;
|
|
323
|
-
};
|
|
324
|
-
brushStyle?: BrushStyle;
|
|
325
|
-
selectedPointStyle?: {
|
|
326
|
-
fill: string;
|
|
327
|
-
stroke: string;
|
|
328
|
-
strokeWidth?: number;
|
|
329
|
-
};
|
|
308
|
+
pointStyle?: Partial<PointStyle>;
|
|
309
|
+
brushStyle?: Partial<BrushStyle>;
|
|
310
|
+
brushLayers?: BrushLayerConfig[];
|
|
311
|
+
maxBrushLayers?: number;
|
|
330
312
|
maxPoints?: number;
|
|
331
313
|
maxUndoSteps?: number;
|
|
332
314
|
maskExportFormat?: 'png' | 'jpeg' | 'jpg';
|
|
333
315
|
maskExportForeground?: 'black' | 'white';
|
|
316
|
+
showToolbar?: boolean;
|
|
317
|
+
showZoomController?: boolean;
|
|
318
|
+
canvasBackground?: string;
|
|
319
|
+
zoomMin?: number;
|
|
320
|
+
zoomMax?: number;
|
|
321
|
+
enableBrush?: boolean;
|
|
334
322
|
}
|
|
335
323
|
|
|
336
324
|
const props = defineProps({
|
|
@@ -343,6 +331,11 @@ const props = defineProps({
|
|
|
343
331
|
type: Object as () => OptionsSource,
|
|
344
332
|
default: () => ({}),
|
|
345
333
|
},
|
|
334
|
+
currentLayer: {
|
|
335
|
+
type: String,
|
|
336
|
+
required: false,
|
|
337
|
+
default: null,
|
|
338
|
+
},
|
|
346
339
|
});
|
|
347
340
|
|
|
348
341
|
const emit = defineEmits([
|
|
@@ -352,6 +345,8 @@ const emit = defineEmits([
|
|
|
352
345
|
"loadError",
|
|
353
346
|
"undoStateChange",
|
|
354
347
|
"redoStateChange",
|
|
348
|
+
"update:currentLayer",
|
|
349
|
+
"layerChange",
|
|
355
350
|
]);
|
|
356
351
|
|
|
357
352
|
const canvasContainer = ref<HTMLElement | undefined>(undefined);
|
|
@@ -384,7 +379,10 @@ const pointAnnotations = ref<PointAnnotation[]>([]);
|
|
|
384
379
|
const pointCounter = ref(1);
|
|
385
380
|
|
|
386
381
|
// 是否显示工具界面
|
|
387
|
-
const
|
|
382
|
+
const hasImage = computed(() => loadStatus.value === 'success');
|
|
383
|
+
const showToolbar = computed(() => hasImage.value && (props.options?.showToolbar !== false));
|
|
384
|
+
const showZoomController = computed(() => hasImage.value && (props.options?.showZoomController !== false));
|
|
385
|
+
const effectiveEnableBrush = computed(() => props.options?.enableBrush !== false);
|
|
388
386
|
|
|
389
387
|
// 点标注样式配置
|
|
390
388
|
const pointStyle = computed<PointStyle>(() => ({
|
|
@@ -409,13 +407,13 @@ watch(brushStyle, (newVal: BrushStyle) => {
|
|
|
409
407
|
localBrushStyle.value = { ...newVal };
|
|
410
408
|
}, { immediate: true });
|
|
411
409
|
|
|
412
|
-
//
|
|
410
|
+
// 监听笔刷透明度变化,更新所有图层的 CanvasBrush 透明度
|
|
413
411
|
watch(
|
|
414
412
|
() => localBrushStyle.value.opacity,
|
|
415
413
|
(newOpacity) => {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
414
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => {
|
|
415
|
+
brush.setOpacity(newOpacity);
|
|
416
|
+
});
|
|
419
417
|
}
|
|
420
418
|
);
|
|
421
419
|
|
|
@@ -444,8 +442,83 @@ watch(
|
|
|
444
442
|
{ immediate: true }
|
|
445
443
|
);
|
|
446
444
|
|
|
445
|
+
// 多图层相关状态
|
|
446
|
+
const MAX_BRUSH_LAYERS = 8;
|
|
447
|
+
const DEFAULT_LAYER_VALUE = 'default';
|
|
448
|
+
|
|
449
|
+
// 计算实际的图层配置
|
|
450
|
+
const effectiveBrushLayers = computed<BrushLayerConfig[]>(() => {
|
|
451
|
+
const configured = props.options?.brushLayers;
|
|
452
|
+
if (!configured || configured.length === 0) {
|
|
453
|
+
return [{
|
|
454
|
+
label: '笔刷',
|
|
455
|
+
value: DEFAULT_LAYER_VALUE,
|
|
456
|
+
color: localBrushStyle.value.color,
|
|
457
|
+
opacity: localBrushStyle.value.opacity,
|
|
458
|
+
size: localBrushStyle.value.size,
|
|
459
|
+
}];
|
|
460
|
+
}
|
|
461
|
+
const maxLayers = props.options?.maxBrushLayers || MAX_BRUSH_LAYERS;
|
|
462
|
+
return configured.slice(0, maxLayers);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// 每个图层独立的 CanvasBrush 实例
|
|
466
|
+
const canvasBrushesByLayer = ref<Record<string, CanvasBrush>>({});
|
|
467
|
+
|
|
468
|
+
// 内部当前激活图层(非受控模式使用)
|
|
469
|
+
const internalCurrentLayer = ref<string>('');
|
|
470
|
+
|
|
471
|
+
// 实际当前激活图层(受控 or 非受控)
|
|
472
|
+
const effectiveCurrentLayer = computed<string>(() => {
|
|
473
|
+
if (props.currentLayer && effectiveBrushLayers.value.some(l => l.value === props.currentLayer)) {
|
|
474
|
+
return props.currentLayer;
|
|
475
|
+
}
|
|
476
|
+
if (internalCurrentLayer.value && effectiveBrushLayers.value.some(l => l.value === internalCurrentLayer.value)) {
|
|
477
|
+
return internalCurrentLayer.value;
|
|
478
|
+
}
|
|
479
|
+
return effectiveBrushLayers.value[0]?.value || DEFAULT_LAYER_VALUE;
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// 当前激活的 canvasBrush
|
|
483
|
+
const activeCanvasBrush = computed<CanvasBrush | null>(() => {
|
|
484
|
+
return canvasBrushesByLayer.value[effectiveCurrentLayer.value] || null;
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// 监听 enableBrush 变化(运行时切换笔刷开关)
|
|
488
|
+
// - 关闭:清除所有笔刷 canvas 并重置 tool
|
|
489
|
+
// - 开启:重新初始化笔刷图层
|
|
490
|
+
watch(
|
|
491
|
+
() => effectiveEnableBrush.value,
|
|
492
|
+
(newVal) => {
|
|
493
|
+
// 关闭笔刷 → 清理画布 + 重置 tool 状态
|
|
494
|
+
if (!newVal) {
|
|
495
|
+
if (currentTool.value === 'brush' || currentTool.value === 'eraser') {
|
|
496
|
+
currentTool.value = 'select';
|
|
497
|
+
}
|
|
498
|
+
showBrushPanel.value = false;
|
|
499
|
+
}
|
|
500
|
+
// 开启笔刷(且已有画布)→ 重新初始化笔刷图层
|
|
501
|
+
if (newVal && imageWidth.value && imageHeight.value && app) {
|
|
502
|
+
initBrushLayers();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// 监听图层配置变化(支持运行时从单图层切到多图层,或切换图层配置)
|
|
508
|
+
watch(
|
|
509
|
+
() => {
|
|
510
|
+
const layers = props.options?.brushLayers;
|
|
511
|
+
return layers ? layers.map(l => l.value).sort().join(',') : '';
|
|
512
|
+
},
|
|
513
|
+
() => {
|
|
514
|
+
if (!effectiveEnableBrush.value) return;
|
|
515
|
+
if (imageWidth.value && imageHeight.value && app) {
|
|
516
|
+
initBrushLayers();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
);
|
|
520
|
+
|
|
447
521
|
// 笔刷相关状态
|
|
448
|
-
let canvasBrush: CanvasBrush | null = null;
|
|
449
522
|
const isDrawing = ref(false);
|
|
450
523
|
|
|
451
524
|
// 撤销/重做管理器
|
|
@@ -466,12 +539,15 @@ const changePointScaleRelativeCanvas = (pointAnnotationLayer: Group | null) => {
|
|
|
466
539
|
}
|
|
467
540
|
|
|
468
541
|
const initCanvas = () => {
|
|
542
|
+
const canvasBackground = props.options?.canvasBackground ?? "#e3e3e3";
|
|
543
|
+
const zoomMin = props.options?.zoomMin ?? 0.2;
|
|
544
|
+
const zoomMax = props.options?.zoomMax ?? 4;
|
|
469
545
|
app = new App({
|
|
470
546
|
view: canvasContainer.value,
|
|
471
547
|
width: canvasContainer.value?.clientWidth || 800,
|
|
472
548
|
height: canvasContainer.value?.clientHeight || 600,
|
|
473
|
-
fill:
|
|
474
|
-
zoom: { min:
|
|
549
|
+
fill: canvasBackground,
|
|
550
|
+
zoom: { min: zoomMin, max: zoomMax },
|
|
475
551
|
editor: {
|
|
476
552
|
rotateable: false,
|
|
477
553
|
middlePoint: {},
|
|
@@ -640,30 +716,40 @@ const handleDrop = (event: DragEvent) => {
|
|
|
640
716
|
};
|
|
641
717
|
|
|
642
718
|
const exportCanvasJSON = (): string => {
|
|
719
|
+
const brushLayersData: Record<string, string> = {};
|
|
720
|
+
Object.entries(canvasBrushesByLayer.value).forEach(([layerValue, brush]) => {
|
|
721
|
+
const maskData = brush.getImageData();
|
|
722
|
+
if (maskData) {
|
|
723
|
+
brushLayersData[layerValue] = maskData;
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
643
727
|
const exportData = {
|
|
644
728
|
version: '1.0',
|
|
645
729
|
imageUrl: hasLocalImage.value ? localImageUrl.value : (props.imageSource?.url || ''),
|
|
646
730
|
imageWidth: imageWidth.value,
|
|
647
731
|
imageHeight: imageHeight.value,
|
|
648
732
|
pointAnnotations: [...pointAnnotations.value],
|
|
649
|
-
|
|
733
|
+
brushLayers: brushLayersData,
|
|
734
|
+
brushMask: activeCanvasBrush.value?.getImageData() || null,
|
|
650
735
|
exportTime: Date.now(),
|
|
651
736
|
};
|
|
652
737
|
return JSON.stringify(exportData, null, 2);
|
|
653
738
|
};
|
|
654
739
|
|
|
655
|
-
//
|
|
656
|
-
|
|
740
|
+
// 内部辅助:把笔刷图层渲染为 HTMLCanvas + mime(二值图)
|
|
741
|
+
// 所有导出方法(dataURL / Blob / File)统一复用这个逻辑,避免重复代码
|
|
742
|
+
const buildMaskCanvas = (
|
|
743
|
+
brush: any,
|
|
744
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
745
|
+
foregroundColor?: 'black' | 'white'
|
|
746
|
+
): Promise<{ canvas: HTMLCanvasElement; mime: string } | null> => {
|
|
657
747
|
return new Promise((resolve) => {
|
|
658
|
-
if (!canvasBrush) {
|
|
659
|
-
resolve(null);
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
748
|
const exportFormat = format || props.options?.maskExportFormat || 'png';
|
|
664
749
|
const fgColor = foregroundColor || props.options?.maskExportForeground || 'black';
|
|
750
|
+
const mime = exportFormat === 'png' ? 'image/png' : 'image/jpeg';
|
|
665
751
|
|
|
666
|
-
const maskData =
|
|
752
|
+
const maskData = brush.getImageData();
|
|
667
753
|
if (!maskData) {
|
|
668
754
|
resolve(null);
|
|
669
755
|
return;
|
|
@@ -701,11 +787,7 @@ const exportMaskImage = (format?: 'png' | 'jpeg' | 'jpg', foregroundColor?: 'bla
|
|
|
701
787
|
}
|
|
702
788
|
ctx.putImageData(imageData, 0, 0);
|
|
703
789
|
|
|
704
|
-
|
|
705
|
-
resolve(canvas.toDataURL('image/png'));
|
|
706
|
-
} else {
|
|
707
|
-
resolve(canvas.toDataURL('image/jpeg', 0.95));
|
|
708
|
-
}
|
|
790
|
+
resolve({ canvas, mime });
|
|
709
791
|
};
|
|
710
792
|
|
|
711
793
|
htmlImg.onerror = () => {
|
|
@@ -716,6 +798,135 @@ const exportMaskImage = (format?: 'png' | 'jpeg' | 'jpg', foregroundColor?: 'bla
|
|
|
716
798
|
});
|
|
717
799
|
};
|
|
718
800
|
|
|
801
|
+
// 辅助函数:导出单个图层的 mask(返回 dataURL)
|
|
802
|
+
const exportSingleLayerMask = async (
|
|
803
|
+
brush: any,
|
|
804
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
805
|
+
foregroundColor?: 'black' | 'white'
|
|
806
|
+
): Promise<string | null> => {
|
|
807
|
+
const result = await buildMaskCanvas(brush, format, foregroundColor);
|
|
808
|
+
if (!result) return null;
|
|
809
|
+
const { canvas, mime } = result;
|
|
810
|
+
return mime === 'image/png'
|
|
811
|
+
? canvas.toDataURL('image/png')
|
|
812
|
+
: canvas.toDataURL('image/jpeg', 0.95);
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// 导出二值图(Mask)- 当前激活图层(笔刷禁用时返回 null)
|
|
816
|
+
const exportMaskImage = (format?: 'png' | 'jpeg' | 'jpg', foregroundColor?: 'black' | 'white'): Promise<string | null> => {
|
|
817
|
+
if (!effectiveEnableBrush.value) return Promise.resolve(null);
|
|
818
|
+
if (!activeCanvasBrush.value) return Promise.resolve(null);
|
|
819
|
+
return exportSingleLayerMask(activeCanvasBrush.value, format, foregroundColor);
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// 导出指定图层的 mask(笔刷禁用时返回 null)
|
|
823
|
+
const exportMaskImageByLayer = (
|
|
824
|
+
layerValue: string,
|
|
825
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
826
|
+
foregroundColor?: 'black' | 'white'
|
|
827
|
+
): Promise<string | null> => {
|
|
828
|
+
if (!effectiveEnableBrush.value) return Promise.resolve(null);
|
|
829
|
+
const brush = canvasBrushesByLayer.value[layerValue];
|
|
830
|
+
if (!brush) return Promise.resolve(null);
|
|
831
|
+
return exportSingleLayerMask(brush, format, foregroundColor);
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// 导出所有图层的 masks(笔刷禁用时返回空对象)
|
|
835
|
+
const exportAllMaskImages = (
|
|
836
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
837
|
+
foregroundColor?: 'black' | 'white'
|
|
838
|
+
): Promise<Record<string, string>> => {
|
|
839
|
+
if (!effectiveEnableBrush.value) return Promise.resolve({});
|
|
840
|
+
return new Promise(async (resolve) => {
|
|
841
|
+
const result: Record<string, string> = {};
|
|
842
|
+
for (const [layerValue, brush] of Object.entries(canvasBrushesByLayer.value)) {
|
|
843
|
+
const mask = await exportSingleLayerMask(brush, format, foregroundColor);
|
|
844
|
+
if (mask) {
|
|
845
|
+
result[layerValue] = mask;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
resolve(result);
|
|
849
|
+
});
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
// canvas.toBlob 的 Promise 版本(避免回调地狱)
|
|
853
|
+
const canvasToBlob = (canvas: HTMLCanvasElement, mime: string): Promise<Blob | null> => {
|
|
854
|
+
return new Promise((resolve) => {
|
|
855
|
+
try {
|
|
856
|
+
canvas.toBlob((blob) => {
|
|
857
|
+
resolve(blob);
|
|
858
|
+
}, mime, mime === 'image/jpeg' ? 0.95 : undefined);
|
|
859
|
+
} catch (_e) {
|
|
860
|
+
resolve(null);
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// 获取指定图层 mask 为 Blob(用于传后端接口上传文件)
|
|
866
|
+
// - layerValue 不传 → 当前激活图层
|
|
867
|
+
// - 笔刷禁用时返回 null
|
|
868
|
+
const getMaskBlob = (
|
|
869
|
+
layerValue?: string,
|
|
870
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
871
|
+
foregroundColor?: 'black' | 'white'
|
|
872
|
+
): Promise<Blob | null> => {
|
|
873
|
+
if (!effectiveEnableBrush.value) return Promise.resolve(null);
|
|
874
|
+
const brush = layerValue
|
|
875
|
+
? canvasBrushesByLayer.value[layerValue]
|
|
876
|
+
: activeCanvasBrush.value;
|
|
877
|
+
if (!brush) return Promise.resolve(null);
|
|
878
|
+
return new Promise(async (resolve) => {
|
|
879
|
+
const result = await buildMaskCanvas(brush, format, foregroundColor);
|
|
880
|
+
if (!result) return resolve(null);
|
|
881
|
+
const blob = await canvasToBlob(result.canvas, result.mime);
|
|
882
|
+
resolve(blob);
|
|
883
|
+
});
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// 获取指定图层 mask 为 File(用于传后端接口上传)
|
|
887
|
+
// - 笔刷禁用时返回 null
|
|
888
|
+
const getMaskFile = (
|
|
889
|
+
layerValue?: string,
|
|
890
|
+
filename?: string,
|
|
891
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
892
|
+
foregroundColor?: 'black' | 'white'
|
|
893
|
+
): Promise<File | null> => {
|
|
894
|
+
if (!effectiveEnableBrush.value) return Promise.resolve(null);
|
|
895
|
+
return new Promise(async (resolve) => {
|
|
896
|
+
const blob = await getMaskBlob(layerValue, format, foregroundColor);
|
|
897
|
+
if (!blob) return resolve(null);
|
|
898
|
+
const mime = format === 'jpeg' || format === 'jpg' ? 'image/jpeg' : 'image/png';
|
|
899
|
+
const ext = format === 'jpeg' || format === 'jpg' ? 'jpg' : 'png';
|
|
900
|
+
const finalName = filename || `mask_${layerValue || 'current'}_${Date.now()}.${ext}`;
|
|
901
|
+
try {
|
|
902
|
+
resolve(new File([blob], finalName, { type: mime }));
|
|
903
|
+
} catch (_e) {
|
|
904
|
+
const fallback: any = blob as any;
|
|
905
|
+
fallback.name = finalName;
|
|
906
|
+
fallback.type = mime;
|
|
907
|
+
resolve(fallback);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// 获取所有图层的 mask Blob(笔刷禁用时返回空对象)
|
|
913
|
+
const getAllMaskBlobs = (
|
|
914
|
+
format?: 'png' | 'jpeg' | 'jpg',
|
|
915
|
+
foregroundColor?: 'black' | 'white'
|
|
916
|
+
): Promise<Record<string, Blob>> => {
|
|
917
|
+
if (!effectiveEnableBrush.value) return Promise.resolve({});
|
|
918
|
+
return new Promise(async (resolve) => {
|
|
919
|
+
const result: Record<string, Blob> = {};
|
|
920
|
+
for (const [layerValue, brush] of Object.entries(canvasBrushesByLayer.value)) {
|
|
921
|
+
const canvasResult = await buildMaskCanvas(brush, format, foregroundColor);
|
|
922
|
+
if (!canvasResult) continue;
|
|
923
|
+
const blob = await canvasToBlob(canvasResult.canvas, canvasResult.mime);
|
|
924
|
+
if (blob) result[layerValue] = blob;
|
|
925
|
+
}
|
|
926
|
+
resolve(result);
|
|
927
|
+
});
|
|
928
|
+
};
|
|
929
|
+
|
|
719
930
|
const exportCOCO = (): string => {
|
|
720
931
|
const coco = exportCOCOFormat(
|
|
721
932
|
pointAnnotations.value,
|
|
@@ -745,13 +956,11 @@ const importCanvasJSON = async (
|
|
|
745
956
|
try {
|
|
746
957
|
const data = JSON.parse(jsonString);
|
|
747
958
|
|
|
748
|
-
// 如果指定了重置缩放
|
|
749
959
|
if (options?.resetZoom) {
|
|
750
960
|
resetZoom();
|
|
751
961
|
fitImageToCanvas();
|
|
752
962
|
}
|
|
753
963
|
|
|
754
|
-
// 清除现有标注
|
|
755
964
|
pointAnnotations.value.forEach((p: any) => {
|
|
756
965
|
const element = pointLayer.findOne(`#${p.id}`);
|
|
757
966
|
if (element) {
|
|
@@ -761,15 +970,13 @@ const importCanvasJSON = async (
|
|
|
761
970
|
});
|
|
762
971
|
pointAnnotations.value = [];
|
|
763
972
|
|
|
764
|
-
//
|
|
765
|
-
|
|
973
|
+
// 清除所有图层的笔刷
|
|
974
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => brush.clear());
|
|
766
975
|
|
|
767
|
-
// 如果有图片URL且与当前不同,加载图片
|
|
768
976
|
if (data.imageUrl && data.imageUrl !== props.imageSource.url) {
|
|
769
977
|
await loadImage(data.imageUrl);
|
|
770
978
|
}
|
|
771
979
|
|
|
772
|
-
// 恢复点标注
|
|
773
980
|
if (data.pointAnnotations && Array.isArray(data.pointAnnotations)) {
|
|
774
981
|
for (const pointData of data.pointAnnotations) {
|
|
775
982
|
const pointElement = new PointAnnotationElement(pointData, pointStyle.value);
|
|
@@ -781,12 +988,22 @@ const importCanvasJSON = async (
|
|
|
781
988
|
}
|
|
782
989
|
}
|
|
783
990
|
|
|
784
|
-
//
|
|
785
|
-
if (data.
|
|
786
|
-
|
|
991
|
+
// 优先恢复多图层笔刷遮罩
|
|
992
|
+
if (data.brushLayers && typeof data.brushLayers === 'object') {
|
|
993
|
+
Object.entries(data.brushLayers).forEach(([layerValue, maskData]) => {
|
|
994
|
+
const brush = canvasBrushesByLayer.value[layerValue];
|
|
995
|
+
if (brush && maskData) {
|
|
996
|
+
brush.restoreImageData(maskData as string);
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
} else if (data.brushMask && activeCanvasBrush.value) {
|
|
1000
|
+
// 向后兼容:单个 brushMask 时恢复到当前图层
|
|
1001
|
+
activeCanvasBrush.value.restoreImageData(data.brushMask);
|
|
787
1002
|
}
|
|
788
1003
|
|
|
789
|
-
//
|
|
1004
|
+
// 重排导入点的序号(确保显示为连续数字,兼容旧数据)
|
|
1005
|
+
renumberSequenceNumbers();
|
|
1006
|
+
|
|
790
1007
|
changePointScaleRelativeCanvas(pointLayer);
|
|
791
1008
|
|
|
792
1009
|
return true;
|
|
@@ -820,11 +1037,13 @@ onMounted(() => {
|
|
|
820
1037
|
pointTool();
|
|
821
1038
|
},
|
|
822
1039
|
b: (event: KeyboardEvent) => {
|
|
1040
|
+
if (!effectiveEnableBrush.value) return;
|
|
823
1041
|
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
824
1042
|
event.preventDefault();
|
|
825
1043
|
brushTool();
|
|
826
1044
|
},
|
|
827
1045
|
e: (event: KeyboardEvent) => {
|
|
1046
|
+
if (!effectiveEnableBrush.value) return;
|
|
828
1047
|
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
829
1048
|
event.preventDefault();
|
|
830
1049
|
eraserTool();
|
|
@@ -969,46 +1188,39 @@ onUnmounted(() => {
|
|
|
969
1188
|
// 工具切换函数
|
|
970
1189
|
const selectTool = () => {
|
|
971
1190
|
currentTool.value = "select";
|
|
972
|
-
// 关闭笔刷配置面板
|
|
973
1191
|
showBrushPanel.value = false;
|
|
974
1192
|
if (!app) return
|
|
975
1193
|
app.editor.config.moveable = false
|
|
976
1194
|
app.editor.config.resizeable = false
|
|
977
|
-
app.editor.config.multipleSelect = true
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
// 启用标签编辑
|
|
981
|
-
updateLabelEditable(true);
|
|
1195
|
+
app.editor.config.multipleSelect = true
|
|
1196
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => brush.setPointerEvents(false));
|
|
1197
|
+
updateLabelEditable(false);
|
|
982
1198
|
};
|
|
983
1199
|
|
|
984
1200
|
const pointTool = () => {
|
|
985
1201
|
currentTool.value = "point";
|
|
986
|
-
// 关闭笔刷配置面板
|
|
987
1202
|
showBrushPanel.value = false;
|
|
988
1203
|
if (!app) return
|
|
989
1204
|
app.editor.config.moveable = true
|
|
990
|
-
app.editor.config.multipleSelect = false
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
// 启用标签编辑
|
|
994
|
-
updateLabelEditable(true);
|
|
1205
|
+
app.editor.config.multipleSelect = false
|
|
1206
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => brush.setPointerEvents(false));
|
|
1207
|
+
updateLabelEditable(false);
|
|
995
1208
|
};
|
|
996
1209
|
|
|
997
|
-
const brushTool = () => {
|
|
1210
|
+
const brushTool = (openPanel?: boolean) => {
|
|
1211
|
+
if (!effectiveEnableBrush.value) return;
|
|
998
1212
|
currentTool.value = "brush";
|
|
999
1213
|
if (!app) return;
|
|
1000
|
-
// 切换到笔刷模式时禁用编辑器
|
|
1001
1214
|
app.editor.config.moveable = false;
|
|
1002
1215
|
app.editor.config.resizeable = false;
|
|
1003
1216
|
app.editor.config.multipleSelect = false;
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1217
|
+
Object.entries(canvasBrushesByLayer.value).forEach(([layerValue, brush]) => {
|
|
1218
|
+
brush.setPointerEvents(layerValue === effectiveCurrentLayer.value);
|
|
1219
|
+
});
|
|
1007
1220
|
updateLabelEditable(false);
|
|
1008
|
-
|
|
1009
|
-
showBrushPanel.value =
|
|
1010
|
-
if (
|
|
1011
|
-
// 获取按钮位置
|
|
1221
|
+
const willOpen = openPanel !== undefined ? openPanel : !showBrushPanel.value;
|
|
1222
|
+
showBrushPanel.value = willOpen;
|
|
1223
|
+
if (willOpen) {
|
|
1012
1224
|
nextTick(() => {
|
|
1013
1225
|
if (brushButtonRef.value) {
|
|
1014
1226
|
brushButtonRect.value = brushButtonRef.value.getBoundingClientRect();
|
|
@@ -1017,63 +1229,110 @@ const brushTool = () => {
|
|
|
1017
1229
|
}
|
|
1018
1230
|
};
|
|
1019
1231
|
|
|
1020
|
-
// 关闭笔刷配置面板
|
|
1021
1232
|
const closeBrushPanel = () => {
|
|
1022
1233
|
showBrushPanel.value = false;
|
|
1023
1234
|
};
|
|
1024
1235
|
|
|
1025
|
-
// 更新笔刷样式
|
|
1026
1236
|
const updateBrushStyle = (style: Partial<BrushStyle>) => {
|
|
1237
|
+
if (!effectiveEnableBrush.value) return;
|
|
1027
1238
|
Object.assign(localBrushStyle.value, style);
|
|
1028
1239
|
};
|
|
1029
1240
|
|
|
1030
1241
|
const eraserTool = () => {
|
|
1242
|
+
if (!effectiveEnableBrush.value) return;
|
|
1031
1243
|
currentTool.value = "eraser";
|
|
1032
|
-
// 关闭笔刷配置面板
|
|
1033
1244
|
showBrushPanel.value = false;
|
|
1034
1245
|
if (!app) return;
|
|
1035
|
-
// 切换到擦除模式时禁用编辑器
|
|
1036
1246
|
app.editor.config.moveable = false;
|
|
1037
1247
|
app.editor.config.resizeable = false;
|
|
1038
1248
|
app.editor.config.multipleSelect = false;
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1249
|
+
Object.entries(canvasBrushesByLayer.value).forEach(([layerValue, brush]) => {
|
|
1250
|
+
brush.setPointerEvents(layerValue === effectiveCurrentLayer.value);
|
|
1251
|
+
});
|
|
1042
1252
|
updateLabelEditable(false);
|
|
1043
1253
|
};
|
|
1044
1254
|
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1255
|
+
// 初始化所有笔刷图层(在图片加载后调用)
|
|
1256
|
+
// 当 enableBrush=false 时,仅做清理工作,不创建任何笔刷 canvas
|
|
1257
|
+
const initBrushLayers = () => {
|
|
1047
1258
|
if (!imageWidth.value || !imageHeight.value || !app) return;
|
|
1048
1259
|
|
|
1049
|
-
//
|
|
1050
|
-
|
|
1051
|
-
|
|
1260
|
+
// 清除所有旧的笔刷
|
|
1261
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => {
|
|
1262
|
+
brush.getGroup().remove();
|
|
1263
|
+
});
|
|
1264
|
+
canvasBrushesByLayer.value = {};
|
|
1265
|
+
|
|
1266
|
+
// 禁用笔刷 → 不创建任何图层
|
|
1267
|
+
if (!effectiveEnableBrush.value) {
|
|
1268
|
+
return;
|
|
1052
1269
|
}
|
|
1053
1270
|
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1271
|
+
// 为每个图层创建独立的 CanvasBrush 实例
|
|
1272
|
+
effectiveBrushLayers.value.forEach((layerConfig) => {
|
|
1273
|
+
const layerBrushStyle: BrushStyle = {
|
|
1274
|
+
color: layerConfig.color || localBrushStyle.value.color,
|
|
1275
|
+
opacity: layerConfig.opacity !== undefined ? layerConfig.opacity : localBrushStyle.value.opacity,
|
|
1276
|
+
size: layerConfig.size || localBrushStyle.value.size,
|
|
1277
|
+
minSize: localBrushStyle.value.minSize,
|
|
1278
|
+
maxSize: localBrushStyle.value.maxSize,
|
|
1279
|
+
continuity: localBrushStyle.value.continuity,
|
|
1280
|
+
};
|
|
1060
1281
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1282
|
+
const brush = new CanvasBrush(
|
|
1283
|
+
imageWidth.value!,
|
|
1284
|
+
imageHeight.value!,
|
|
1285
|
+
layerBrushStyle
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
contentLayer.add(brush.getGroup());
|
|
1289
|
+
canvasBrushesByLayer.value[layerConfig.value] = brush;
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// 默认选中第一个图层
|
|
1293
|
+
if (!internalCurrentLayer.value) {
|
|
1294
|
+
internalCurrentLayer.value = effectiveBrushLayers.value[0]?.value || DEFAULT_LAYER_VALUE;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
updateAllLayersVisibility();
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
const updateAllLayersVisibility = () => {
|
|
1301
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => {
|
|
1302
|
+
const group = brush.getGroup();
|
|
1303
|
+
if (group) {
|
|
1304
|
+
(group as any).visible = true;
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
// 切换当前图层
|
|
1310
|
+
const setActiveLayer = (layerValue: string) => {
|
|
1311
|
+
if (!effectiveBrushLayers.value.some(l => l.value === layerValue)) return;
|
|
1312
|
+
internalCurrentLayer.value = layerValue;
|
|
1313
|
+
if (!props.currentLayer) {
|
|
1314
|
+
emit('update:currentLayer', layerValue);
|
|
1315
|
+
emit('layerChange', layerValue);
|
|
1316
|
+
}
|
|
1063
1317
|
};
|
|
1064
1318
|
|
|
1319
|
+
// 兼容旧代码
|
|
1320
|
+
const initBrushLayer = initBrushLayers;
|
|
1321
|
+
|
|
1065
1322
|
// 笔刷绘制事件处理
|
|
1066
1323
|
let brushSnapshotBeforeDraw: string | null = null;
|
|
1324
|
+
let brushSnapshotLayer: string | null = null;
|
|
1067
1325
|
|
|
1068
1326
|
const handleBrushDown = (e: any) => {
|
|
1069
1327
|
if (currentTool.value !== 'brush' && currentTool.value !== 'eraser') return;
|
|
1070
|
-
if (!app || !imageBox || !
|
|
1328
|
+
if (!app || !imageBox || !activeCanvasBrush.value) return;
|
|
1071
1329
|
|
|
1072
1330
|
isDrawing.value = true;
|
|
1331
|
+
brushSnapshotLayer = effectiveCurrentLayer.value;
|
|
1073
1332
|
|
|
1074
1333
|
// 保存当前画布快照(用于撤销)
|
|
1075
1334
|
if (commandManager) {
|
|
1076
|
-
brushSnapshotBeforeDraw =
|
|
1335
|
+
brushSnapshotBeforeDraw = activeCanvasBrush.value.getImageData();
|
|
1077
1336
|
}
|
|
1078
1337
|
|
|
1079
1338
|
// 获取相对于图片的坐标(与点标注相同的方式)
|
|
@@ -1082,11 +1341,11 @@ const handleBrushDown = (e: any) => {
|
|
|
1082
1341
|
// 判断是否为擦除模式
|
|
1083
1342
|
const isErase = currentTool.value === 'eraser';
|
|
1084
1343
|
|
|
1085
|
-
//
|
|
1344
|
+
// 根据模式绘制到当前激活图层
|
|
1086
1345
|
if (isErase) {
|
|
1087
|
-
|
|
1346
|
+
activeCanvasBrush.value.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
|
|
1088
1347
|
} else {
|
|
1089
|
-
|
|
1348
|
+
activeCanvasBrush.value.draw(
|
|
1090
1349
|
point.x,
|
|
1091
1350
|
point.y,
|
|
1092
1351
|
localBrushStyle.value.size,
|
|
@@ -1097,11 +1356,11 @@ const handleBrushDown = (e: any) => {
|
|
|
1097
1356
|
}
|
|
1098
1357
|
|
|
1099
1358
|
// 触发 Canvas 重绘
|
|
1100
|
-
|
|
1359
|
+
activeCanvasBrush.value.getCanvas().paint();
|
|
1101
1360
|
};
|
|
1102
1361
|
|
|
1103
1362
|
const handleBrushMove = (e: any) => {
|
|
1104
|
-
if (!isDrawing.value || !
|
|
1363
|
+
if (!isDrawing.value || !activeCanvasBrush.value || !imageBox) return;
|
|
1105
1364
|
|
|
1106
1365
|
// 获取相对于图片的坐标(与点标注相同的方式)
|
|
1107
1366
|
const point = contentLayer.getBoxPoint({ x: e.x, y: e.y });
|
|
@@ -1109,11 +1368,11 @@ const handleBrushMove = (e: any) => {
|
|
|
1109
1368
|
// 判断是否为擦除模式
|
|
1110
1369
|
const isErase = currentTool.value === 'eraser';
|
|
1111
1370
|
|
|
1112
|
-
//
|
|
1371
|
+
// 根据模式绘制到当前激活图层
|
|
1113
1372
|
if (isErase) {
|
|
1114
|
-
|
|
1373
|
+
activeCanvasBrush.value.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
|
|
1115
1374
|
} else {
|
|
1116
|
-
|
|
1375
|
+
activeCanvasBrush.value.draw(
|
|
1117
1376
|
point.x,
|
|
1118
1377
|
point.y,
|
|
1119
1378
|
localBrushStyle.value.size,
|
|
@@ -1124,31 +1383,32 @@ const handleBrushMove = (e: any) => {
|
|
|
1124
1383
|
}
|
|
1125
1384
|
|
|
1126
1385
|
// 触发 Canvas 重绘
|
|
1127
|
-
|
|
1386
|
+
activeCanvasBrush.value.getCanvas().paint();
|
|
1128
1387
|
};
|
|
1129
1388
|
|
|
1130
1389
|
const handleBrushUp = () => {
|
|
1131
1390
|
isDrawing.value = false;
|
|
1132
1391
|
|
|
1133
|
-
//
|
|
1134
|
-
if (commandManager &&
|
|
1135
|
-
const snapshotCommand = new BrushSnapshotCommand(
|
|
1392
|
+
// 如果有保存的快照,创建撤销命令(使用操作开始时的图层)
|
|
1393
|
+
if (commandManager && brushSnapshotLayer && canvasBrushesByLayer.value[brushSnapshotLayer] && brushSnapshotBeforeDraw) {
|
|
1394
|
+
const snapshotCommand = new BrushSnapshotCommand(canvasBrushesByLayer.value[brushSnapshotLayer], brushSnapshotBeforeDraw);
|
|
1136
1395
|
commandManager.executeCommand(snapshotCommand);
|
|
1137
1396
|
brushSnapshotBeforeDraw = null;
|
|
1397
|
+
brushSnapshotLayer = null;
|
|
1138
1398
|
}
|
|
1139
1399
|
|
|
1140
1400
|
// 重置上一个点,避免下次绘制时从上次结束的地方连线
|
|
1141
|
-
|
|
1401
|
+
activeCanvasBrush.value?.resetLastPoint();
|
|
1142
1402
|
};
|
|
1143
1403
|
|
|
1144
1404
|
// 生成 UUID
|
|
1145
|
-
const generateUUID = (): string => {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
};
|
|
1405
|
+
// const generateUUID = (): string => {
|
|
1406
|
+
// return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
1407
|
+
// const r = Math.random() * 16 | 0;
|
|
1408
|
+
// const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
1409
|
+
// return v.toString(16);
|
|
1410
|
+
// });
|
|
1411
|
+
// };
|
|
1152
1412
|
|
|
1153
1413
|
// 处理画布点击事件
|
|
1154
1414
|
const handleCanvasTap = (e: any) => {
|
|
@@ -1177,54 +1437,80 @@ const handleCanvasTap = (e: any) => {
|
|
|
1177
1437
|
createPointAnnotation(point.x, point.y);
|
|
1178
1438
|
};
|
|
1179
1439
|
|
|
1440
|
+
// 处理点击【标注点】选中样式
|
|
1441
|
+
// const handlePointAnnotationSelected = (e: any) => {
|
|
1442
|
+
// if (currentTool.value === 'brush' || currentTool.value === 'eraser' || !app || !imageBox) return;
|
|
1443
|
+
// // console.log(e)
|
|
1444
|
+
// if (e.value) {
|
|
1445
|
+
// if (Array.isArray(e.value)) {
|
|
1446
|
+
// e.value.forEach((element: { circle: { set: (arg0: { fill: string; stroke: string, selected: boolean; }) => void; }; }) => {
|
|
1447
|
+
// if (!element.circle) return
|
|
1448
|
+
// element.circle.set({
|
|
1449
|
+
// fill: pointStyle.value.selectedCircleFill,
|
|
1450
|
+
// stroke: pointStyle.value.selectedCircleStroke,
|
|
1451
|
+
// selected: true,
|
|
1452
|
+
// })
|
|
1453
|
+
// });
|
|
1454
|
+
// } else {
|
|
1455
|
+
// const _target = e.value.circle || e.value.parent.circle
|
|
1456
|
+
// if (!_target) return
|
|
1457
|
+
// _target.set({
|
|
1458
|
+
// fill: pointStyle.value.selectedCircleFill,
|
|
1459
|
+
// stroke: pointStyle.value.selectedCircleStroke,
|
|
1460
|
+
// selected: true,
|
|
1461
|
+
// })
|
|
1462
|
+
// }
|
|
1463
|
+
// }
|
|
1464
|
+
// if (e.oldValue && (!Array.isArray(e.oldValue) || !e.value)) {
|
|
1465
|
+
// if (Array.isArray(e.oldValue)) {
|
|
1466
|
+
// e.oldValue.forEach((element: { circle: { set: (arg0: { fill: string; stroke: string, selected: boolean; }) => void; }; }) => {
|
|
1467
|
+
// if (!element.circle) return
|
|
1468
|
+
// element.circle.set({
|
|
1469
|
+
// fill: pointStyle.value.circleFill,
|
|
1470
|
+
// stroke: pointStyle.value.circleStroke,
|
|
1471
|
+
// selected: false,
|
|
1472
|
+
// })
|
|
1473
|
+
// });
|
|
1474
|
+
// } else {
|
|
1475
|
+
// const _target = e.oldValue.circle || e.oldValue.parent.circle
|
|
1476
|
+
// if (!_target || (e.oldValue === e.value?.parent)) return
|
|
1477
|
+
// _target.set({
|
|
1478
|
+
// fill: pointStyle.value.circleFill,
|
|
1479
|
+
// stroke: pointStyle.value.circleStroke,
|
|
1480
|
+
// selected: false,
|
|
1481
|
+
// })
|
|
1482
|
+
// }
|
|
1483
|
+
// }
|
|
1484
|
+
// }
|
|
1485
|
+
|
|
1180
1486
|
// 处理点击【标注点】选中样式
|
|
1181
1487
|
const handlePointAnnotationSelected = (e: any) => {
|
|
1182
1488
|
if (currentTool.value === 'brush' || currentTool.value === 'eraser' || !app || !imageBox) return;
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
stroke: pointStyle.value.selectedCircleStroke
|
|
1199
|
-
})
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
if (e.oldValue && (!Array.isArray(e.oldValue) || !e.value)) {
|
|
1203
|
-
if (Array.isArray(e.oldValue)) {
|
|
1204
|
-
e.oldValue.forEach((element: { circle: { set: (arg0: { fill: string; stroke: string; }) => void; }; }) => {
|
|
1205
|
-
if (!element.circle) return
|
|
1206
|
-
element.circle.set({
|
|
1207
|
-
fill: pointStyle.value.circleFill,
|
|
1208
|
-
stroke: pointStyle.value.circleStroke
|
|
1209
|
-
})
|
|
1210
|
-
});
|
|
1211
|
-
} else {
|
|
1212
|
-
const _target = e.oldValue.circle || e.oldValue.parent.circle
|
|
1213
|
-
if (!_target || (e.oldValue === e.value?.parent)) return
|
|
1214
|
-
_target.set({
|
|
1215
|
-
fill: pointStyle.value.circleFill,
|
|
1216
|
-
stroke: pointStyle.value.circleStroke
|
|
1217
|
-
})
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1489
|
+
|
|
1490
|
+
// 收集新选中的所有点的 id(可能是单个,也可能是数组)
|
|
1491
|
+
const selectedIds = new Set<string>();
|
|
1492
|
+
const newValues = Array.isArray(e.value) ? e.value : (e.value ? [e.value] : []);
|
|
1493
|
+
newValues.forEach((element: any) => {
|
|
1494
|
+
if (element.data?.id) selectedIds.add(element.data.id);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
// 遍历所有标注点,逐个设置状态
|
|
1498
|
+
pointLayer.children.forEach((p: any) => {
|
|
1499
|
+
if (p._element_tag !== 'point-annotation') return;
|
|
1500
|
+
const isSelected = selectedIds.has(p.data?.id);
|
|
1501
|
+
p.handlePointAnnotationSelected?.(isSelected);
|
|
1502
|
+
});
|
|
1503
|
+
};
|
|
1221
1504
|
|
|
1222
1505
|
// 创建点标注
|
|
1223
1506
|
const createPointAnnotation = (pixelX: number, pixelY: number): string | null => {
|
|
1224
1507
|
if (!imageWidth.value || !imageHeight.value) return null;
|
|
1225
1508
|
|
|
1226
|
-
const id = `point_${generateUUID()}`;
|
|
1227
|
-
const
|
|
1509
|
+
// const id = `point_${generateUUID()}`;
|
|
1510
|
+
const id = `point_${pointCounter.value}`
|
|
1511
|
+
// const label = `#${pointCounter.value}`;
|
|
1512
|
+
const order = pointCounter.value;
|
|
1513
|
+
const sequenceNumber = pointAnnotations.value.length + 1;
|
|
1228
1514
|
|
|
1229
1515
|
// 计算归一化坐标
|
|
1230
1516
|
const normalizedX = pixelX / imageWidth.value;
|
|
@@ -1232,9 +1518,11 @@ const createPointAnnotation = (pixelX: number, pixelY: number): string | null =>
|
|
|
1232
1518
|
|
|
1233
1519
|
const pointData: PointAnnotation = {
|
|
1234
1520
|
id,
|
|
1521
|
+
order,
|
|
1522
|
+
sequenceNumber,
|
|
1235
1523
|
pixel: { x: pixelX, y: pixelY },
|
|
1236
1524
|
normalized: { x: normalizedX, y: normalizedY },
|
|
1237
|
-
label,
|
|
1525
|
+
// label,
|
|
1238
1526
|
createdAt: Date.now(),
|
|
1239
1527
|
updatedAt: Date.now(),
|
|
1240
1528
|
};
|
|
@@ -1243,6 +1531,11 @@ const createPointAnnotation = (pixelX: number, pixelY: number): string | null =>
|
|
|
1243
1531
|
const pointElement = new PointAnnotationElement(pointData, pointStyle.value);
|
|
1244
1532
|
|
|
1245
1533
|
// 根据当前工具设置标签的可编辑状态
|
|
1534
|
+
// 注意:由于 PointAnnotationElement.hitChildren = false,子元素无法接收鼠标事件,
|
|
1535
|
+
// 这里设置 label.editable 实际**不会产生视觉效果**(label 永远收不到双击/点击进入编辑)。
|
|
1536
|
+
// 保留这段逻辑是为了:
|
|
1537
|
+
// 1) 状态上保持一致性(工具切换时设置正确的可编辑状态)
|
|
1538
|
+
// 2) 如果未来把 hitChildren 改回 true,这个设置立即生效
|
|
1246
1539
|
if (currentTool.value === 'brush' || currentTool.value === 'eraser') {
|
|
1247
1540
|
pointElement.label.editable = false;
|
|
1248
1541
|
}
|
|
@@ -1274,7 +1567,7 @@ const createPointAnnotation = (pixelX: number, pixelY: number): string | null =>
|
|
|
1274
1567
|
|
|
1275
1568
|
// 删除选中的点标注或清除笔刷内容
|
|
1276
1569
|
const deleteSelected = () => {
|
|
1277
|
-
//
|
|
1570
|
+
// 如果当前工具是笔刷,清除所有笔刷内容(笔刷禁用时不会走到这里)
|
|
1278
1571
|
if (currentTool.value === 'brush' || currentTool.value === 'eraser') {
|
|
1279
1572
|
clearBrush();
|
|
1280
1573
|
return;
|
|
@@ -1283,8 +1576,16 @@ const deleteSelected = () => {
|
|
|
1283
1576
|
// select 模式下未选中任何元素,清除所有
|
|
1284
1577
|
if (currentTool.value === 'select') {
|
|
1285
1578
|
const selected = app?.editor?.list || [];
|
|
1286
|
-
|
|
1287
|
-
|
|
1579
|
+
// 禁用笔刷时只关心点标注
|
|
1580
|
+
let hasBrushContent = false;
|
|
1581
|
+
if (effectiveEnableBrush.value) {
|
|
1582
|
+
hasBrushContent = Object.values(canvasBrushesByLayer.value).some(brush => brush?.hasContent?.());
|
|
1583
|
+
}
|
|
1584
|
+
if (selected.length === 0 && (pointAnnotations.value.length > 0 || hasBrushContent)) {
|
|
1585
|
+
const confirmMsg = effectiveEnableBrush.value
|
|
1586
|
+
? '确定清除所有标注和笔刷绘制区域吗?'
|
|
1587
|
+
: '确定清除所有标注吗?';
|
|
1588
|
+
if (confirm(confirmMsg)) {
|
|
1288
1589
|
clearAllAnnotationsAndBrush();
|
|
1289
1590
|
}
|
|
1290
1591
|
return;
|
|
@@ -1317,6 +1618,9 @@ const deleteSelected = () => {
|
|
|
1317
1618
|
// 清除编辑器选择
|
|
1318
1619
|
app.editor.cancel();
|
|
1319
1620
|
|
|
1621
|
+
// 重排剩余点的序号
|
|
1622
|
+
renumberSequenceNumbers();
|
|
1623
|
+
|
|
1320
1624
|
// 触发事件
|
|
1321
1625
|
emit("pointChange", [...pointAnnotations.value]);
|
|
1322
1626
|
};
|
|
@@ -1333,40 +1637,112 @@ const clearAllAnnotationsAndBrush = () => {
|
|
|
1333
1637
|
});
|
|
1334
1638
|
pointAnnotations.value = [];
|
|
1335
1639
|
|
|
1336
|
-
//
|
|
1337
|
-
|
|
1640
|
+
// 清除所有图层的笔刷(仅当启用笔刷时)
|
|
1641
|
+
if (effectiveEnableBrush.value) {
|
|
1642
|
+
Object.values(canvasBrushesByLayer.value).forEach(brush => brush.clear());
|
|
1643
|
+
}
|
|
1338
1644
|
|
|
1339
1645
|
emit("pointChange", []);
|
|
1340
1646
|
};
|
|
1341
1647
|
|
|
1342
|
-
//
|
|
1648
|
+
// 清除当前图层的笔刷内容(仅当启用笔刷时)
|
|
1343
1649
|
const clearBrush = () => {
|
|
1344
|
-
if (
|
|
1650
|
+
if (!effectiveEnableBrush.value) return;
|
|
1651
|
+
if (activeCanvasBrush.value) {
|
|
1345
1652
|
if (commandManager) {
|
|
1346
|
-
const beforeSnapshot =
|
|
1347
|
-
const snapshotCommand = new BrushSnapshotCommand(
|
|
1653
|
+
const beforeSnapshot = activeCanvasBrush.value.getImageData();
|
|
1654
|
+
const snapshotCommand = new BrushSnapshotCommand(activeCanvasBrush.value, beforeSnapshot, true);
|
|
1348
1655
|
commandManager.executeCommand(snapshotCommand);
|
|
1349
1656
|
}
|
|
1350
1657
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1658
|
+
activeCanvasBrush.value.clear();
|
|
1659
|
+
activeCanvasBrush.value.getCanvas().paint();
|
|
1353
1660
|
}
|
|
1354
1661
|
};
|
|
1355
1662
|
|
|
1663
|
+
// 清除所有图层的笔刷内容(仅当启用笔刷时)
|
|
1664
|
+
const clearAllBrushLayers = () => {
|
|
1665
|
+
if (!effectiveEnableBrush.value) return;
|
|
1666
|
+
Object.entries(canvasBrushesByLayer.value).forEach(([_layerValue, brush]) => {
|
|
1667
|
+
if (commandManager) {
|
|
1668
|
+
const beforeSnapshot = brush.getImageData();
|
|
1669
|
+
const snapshotCommand = new BrushSnapshotCommand(brush, beforeSnapshot, true);
|
|
1670
|
+
commandManager.executeCommand(snapshotCommand);
|
|
1671
|
+
}
|
|
1672
|
+
brush.clear();
|
|
1673
|
+
brush.getCanvas().paint();
|
|
1674
|
+
});
|
|
1675
|
+
};
|
|
1676
|
+
|
|
1677
|
+
// 获取当前激活图层
|
|
1678
|
+
const getCurrentLayer = (): string => {
|
|
1679
|
+
return effectiveCurrentLayer.value;
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
// 获取所有图层配置
|
|
1683
|
+
const getAllLayers = (): BrushLayerConfig[] => {
|
|
1684
|
+
return [...effectiveBrushLayers.value];
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1356
1687
|
// 获取点标注数据
|
|
1357
1688
|
const getPointAnnotations = (): PointAnnotation[] => {
|
|
1358
1689
|
return [...pointAnnotations.value];
|
|
1359
1690
|
};
|
|
1360
1691
|
|
|
1692
|
+
// 更新指定标注点的 label(父组件通过 id 找到对应元素并更新名称)
|
|
1693
|
+
// 支持点标注后,父组件通过此方法改点标注点的名称
|
|
1694
|
+
const updatePointAnnotationLabel = (id: string, label: string): boolean => {
|
|
1695
|
+
const element = pointLayer.children.find((el: any) => el.data?.id === id) as PointAnnotationElement;
|
|
1696
|
+
if (!element) return false;
|
|
1697
|
+
if (element.updateLabel) {
|
|
1698
|
+
element.updateLabel(label);
|
|
1699
|
+
}
|
|
1700
|
+
emit("pointChange", [...pointAnnotations.value]);
|
|
1701
|
+
return true;
|
|
1702
|
+
};
|
|
1703
|
+
|
|
1704
|
+
// 根据所有标注点的轨迹,在当前笔刷层中生成闭合多边形填充区域
|
|
1705
|
+
// - 点 < 3 不操作(无法形成多边形)
|
|
1706
|
+
// - 自动按 sequenceNumber 排序
|
|
1707
|
+
// - 使用当前笔刷的 color、opacity
|
|
1708
|
+
// - 支持撤销/重做
|
|
1709
|
+
// - 笔刷禁用时返回 false
|
|
1710
|
+
const createBrushFromPoints = (): boolean => {
|
|
1711
|
+
if (!effectiveEnableBrush.value) return false;
|
|
1712
|
+
const brush = activeCanvasBrush.value;
|
|
1713
|
+
if (!brush) return false;
|
|
1714
|
+
const points = [...pointAnnotations.value];
|
|
1715
|
+
if (points.length < 3) return false;
|
|
1716
|
+
points.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
1717
|
+
const pixelPoints = points.map(p => ({ x: p.pixel.x, y: p.pixel.y }));
|
|
1718
|
+
|
|
1719
|
+
// 操作前保存快照(BrushSnapshotCommand 会在第一次 execute 时自动保存操作后快照)
|
|
1720
|
+
const beforeSnapshot = brush.getImageData();
|
|
1721
|
+
|
|
1722
|
+
// 执行填充多边形(color 就是笔刷的颜色;opacity 由外层 Group 控制,canvas 上设为 1)
|
|
1723
|
+
brush.fillPolygon(pixelPoints, localBrushStyle.value.color);
|
|
1724
|
+
|
|
1725
|
+
// 包装为撤销命令(第一次 execute 时 BrushSnapshotCommand 会自动保存操作后快照)
|
|
1726
|
+
if (commandManager) {
|
|
1727
|
+
const snapshotCommand = new BrushSnapshotCommand(brush, beforeSnapshot);
|
|
1728
|
+
commandManager.executeCommand(snapshotCommand);
|
|
1729
|
+
}
|
|
1730
|
+
return true;
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1361
1733
|
const undo = () => {
|
|
1362
1734
|
if (commandManager?.canUndo()) {
|
|
1363
1735
|
commandManager.undo();
|
|
1736
|
+
renumberSequenceNumbers();
|
|
1737
|
+
emit("pointChange", [...pointAnnotations.value]);
|
|
1364
1738
|
}
|
|
1365
1739
|
};
|
|
1366
1740
|
|
|
1367
1741
|
const redo = () => {
|
|
1368
1742
|
if (commandManager?.canRedo()) {
|
|
1369
1743
|
commandManager.redo();
|
|
1744
|
+
renumberSequenceNumbers();
|
|
1745
|
+
emit("pointChange", [...pointAnnotations.value]);
|
|
1370
1746
|
}
|
|
1371
1747
|
};
|
|
1372
1748
|
|
|
@@ -1375,9 +1751,30 @@ const getCurrentTool = (): "select" | "point" | "brush" | "eraser" => {
|
|
|
1375
1751
|
};
|
|
1376
1752
|
|
|
1377
1753
|
const setTool = (tool: "select" | "point" | "brush" | "eraser") => {
|
|
1754
|
+
// 笔刷禁用时,不允许切到 brush/eraser
|
|
1755
|
+
if (!effectiveEnableBrush.value && (tool === 'brush' || tool === 'eraser')) {
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1378
1758
|
currentTool.value = tool;
|
|
1379
1759
|
};
|
|
1380
1760
|
|
|
1761
|
+
// 根据当前数组顺序重排所有点的显示序号(sequenceNumber)
|
|
1762
|
+
const renumberSequenceNumbers = () => {
|
|
1763
|
+
// 1. 更新数据中的 sequenceNumber
|
|
1764
|
+
pointAnnotations.value.forEach((p, i) => {
|
|
1765
|
+
p.sequenceNumber = i + 1;
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
// 2. 按数据顺序查找对应 DOM 元素并更新 circleText.text
|
|
1769
|
+
// (注意:pointLayer.children 顺序可能与 pointAnnotations 不一致,比如 undo/redo 后)
|
|
1770
|
+
pointAnnotations.value.forEach((p, i) => {
|
|
1771
|
+
const child = pointLayer.children.find((el: any) => el.data?.id === p.id) as PointAnnotationElement;
|
|
1772
|
+
if (child?.updateSequenceNumber) {
|
|
1773
|
+
child.updateSequenceNumber(i + 1);
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1381
1778
|
const removePointAnnotation = (id: string): boolean => {
|
|
1382
1779
|
const index = pointAnnotations.value.findIndex(p => p.id === id);
|
|
1383
1780
|
if (index === -1) return false;
|
|
@@ -1394,6 +1791,9 @@ const removePointAnnotation = (id: string): boolean => {
|
|
|
1394
1791
|
pointAnnotations.value.splice(index, 1);
|
|
1395
1792
|
}
|
|
1396
1793
|
|
|
1794
|
+
// 重排所有点的序号
|
|
1795
|
+
renumberSequenceNumbers();
|
|
1796
|
+
|
|
1397
1797
|
emit("pointChange", [...pointAnnotations.value]);
|
|
1398
1798
|
return true;
|
|
1399
1799
|
};
|
|
@@ -1428,11 +1828,18 @@ defineExpose({
|
|
|
1428
1828
|
getImageInfo,
|
|
1429
1829
|
exportCanvasJSON,
|
|
1430
1830
|
exportMaskImage,
|
|
1831
|
+
exportMaskImageByLayer,
|
|
1832
|
+
exportAllMaskImages,
|
|
1833
|
+
getMaskBlob,
|
|
1834
|
+
getMaskFile,
|
|
1835
|
+
getAllMaskBlobs,
|
|
1431
1836
|
exportCOCO,
|
|
1432
1837
|
exportYOLO,
|
|
1433
1838
|
importCanvasJSON,
|
|
1434
1839
|
loadImage,
|
|
1435
1840
|
clearBrush,
|
|
1841
|
+
clearAllBrushLayers,
|
|
1842
|
+
clearAllAnnotationsAndBrush,
|
|
1436
1843
|
zoomIn,
|
|
1437
1844
|
zoomOut,
|
|
1438
1845
|
resetZoom,
|
|
@@ -1440,8 +1847,20 @@ defineExpose({
|
|
|
1440
1847
|
redo,
|
|
1441
1848
|
getCurrentTool,
|
|
1442
1849
|
setTool,
|
|
1850
|
+
selectTool,
|
|
1851
|
+
pointTool,
|
|
1852
|
+
brushTool,
|
|
1853
|
+
eraserTool,
|
|
1854
|
+
deleteSelected,
|
|
1855
|
+
getCurrentLayer,
|
|
1856
|
+
setActiveLayer,
|
|
1857
|
+
getAllLayers,
|
|
1443
1858
|
createPointAnnotation,
|
|
1444
1859
|
removePointAnnotation,
|
|
1860
|
+
updatePointAnnotationLabel,
|
|
1861
|
+
createBrushFromPoints,
|
|
1862
|
+
getBrushStyle: () => ({ ...localBrushStyle.value }),
|
|
1863
|
+
updateBrushStyle,
|
|
1445
1864
|
});
|
|
1446
1865
|
|
|
1447
1866
|
declare global {
|
|
@@ -1859,4 +2278,5 @@ declare global {
|
|
|
1859
2278
|
color: var(--leafer-point-color-primary);
|
|
1860
2279
|
font-weight: 600;
|
|
1861
2280
|
}
|
|
2281
|
+
|
|
1862
2282
|
</style>
|