@zzalai/leafer-point-annotation 1.1.0 → 1.1.2

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.
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  class="point-annotation"
4
- :class="{ 'has-image': showTools }"
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="showTools" class="zoom-controller">
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="showTools" class="toolbar">
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
- <button
160
- class="tool-button"
161
- :class="{ active: currentTool === 'brush' }"
162
- title="笔刷工具 (B)"
163
- @click="brushTool"
164
- ref="brushButtonRef"
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
- <path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"></path>
178
- <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>
179
- </svg>
180
- <span class="hotkey-hint" v-if="showHotkeys">B</span>
181
- </button>
182
- <button
183
- class="tool-button"
184
- :class="{ active: currentTool === 'eraser' }"
185
- title="橡皮擦工具 (E)"
186
- @click="eraserTool"
187
- >
188
- <svg
189
- xmlns="http://www.w3.org/2000/svg"
190
- width="20"
191
- height="20"
192
- viewBox="0 0 24 24"
193
- fill="none"
194
- stroke="currentColor"
195
- stroke-width="2"
196
- stroke-linecap="round"
197
- stroke-linejoin="round"
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
- <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>
200
- </svg>
201
- <span class="hotkey-hint" v-if="showHotkeys">E</span>
202
- </button>
203
- <!-- 笔刷大小调整 -->
204
- <!-- <div v-if="currentTool === 'brush' || currentTool === 'eraser'" class="size-control">
205
- <div class="size-label">大小</div>
206
- <input
207
- type="range"
208
- class="size-slider"
209
- :min="localBrushStyle.minSize"
210
- :max="localBrushStyle.maxSize"
211
- v-model="localBrushStyle.size"
212
- />
213
- <div class="size-value">{{ localBrushStyle.size }}</div>
214
- </div> -->
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
- fill: string;
319
- stroke: string;
320
- strokeWidth: number;
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 showTools = computed(() => loadStatus.value === 'success');
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
- // 监听笔刷透明度变化,更新 CanvasBrush 的 Group 透明度
410
+ // 监听笔刷透明度变化,更新所有图层的 CanvasBrush 透明度
413
411
  watch(
414
412
  () => localBrushStyle.value.opacity,
415
413
  (newOpacity) => {
416
- if (canvasBrush) {
417
- canvasBrush.setOpacity(newOpacity);
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: "#e3e3e3",
474
- zoom: { min: 0.2, max: 4 },
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
- brushMask: canvasBrush?.getImageData() || null,
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
- // 导出二值图(Mask)
656
- const exportMaskImage = (format?: 'png' | 'jpeg' | 'jpg', foregroundColor?: 'black' | 'white'): Promise<string | null> => {
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 = canvasBrush.getImageData();
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
- if (exportFormat === 'png') {
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
- canvasBrush?.clear();
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.brushMask && canvasBrush) {
786
- canvasBrush.restoreImageData(data.brushMask);
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
- // 强制【标注点】不跟随画布Scale变化
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
- // 禁用笔刷 Canvas 的点击事件,让点击穿透到图片
979
- canvasBrush?.setPointerEvents(false);
980
- // 启用标签编辑
1195
+ app.editor.config.multipleSelect = true
1196
+ Object.values(canvasBrushesByLayer.value).forEach(brush => brush.setPointerEvents(false));
981
1197
  updateLabelEditable(true);
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
- // 禁用笔刷 Canvas 的点击事件,让点击穿透到图片
992
- canvasBrush?.setPointerEvents(false);
993
- // 启用标签编辑
1205
+ app.editor.config.multipleSelect = false
1206
+ Object.values(canvasBrushesByLayer.value).forEach(brush => brush.setPointerEvents(false));
994
1207
  updateLabelEditable(true);
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
- // 启用笔刷 Canvas 的点击事件
1005
- canvasBrush?.setPointerEvents(true);
1006
- // 禁用标签编辑
1217
+ Object.entries(canvasBrushesByLayer.value).forEach(([layerValue, brush]) => {
1218
+ brush.setPointerEvents(layerValue === effectiveCurrentLayer.value);
1219
+ });
1007
1220
  updateLabelEditable(false);
1008
- // 显示配置面板
1009
- showBrushPanel.value = !showBrushPanel.value;
1010
- if (showBrushPanel.value) {
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
- // 启用笔刷 Canvas 的点击事件
1040
- canvasBrush?.setPointerEvents(true);
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
- const initBrushLayer = () => {
1255
+ // 初始化所有笔刷图层(在图片加载后调用)
1256
+ // enableBrush=false 时,仅做清理工作,不创建任何笔刷 canvas
1257
+ const initBrushLayers = () => {
1047
1258
  if (!imageWidth.value || !imageHeight.value || !app) return;
1048
1259
 
1049
- // 清除旧的笔刷
1050
- if (canvasBrush) {
1051
- canvasBrush.getGroup().remove();
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
- // 创建新的 CanvasBrush 实例
1055
- canvasBrush = new CanvasBrush(
1056
- imageWidth.value,
1057
- imageHeight.value,
1058
- localBrushStyle.value
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
+ };
1281
+
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
+ });
1060
1291
 
1061
- // 将 LeaferJS Group 添加到 contentLayer
1062
- contentLayer.add(canvasBrush.getGroup());
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
+ });
1063
1307
  };
1064
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
+ }
1317
+ };
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 || !canvasBrush) return;
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 = canvasBrush.getImageData();
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
- canvasBrush.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
1346
+ activeCanvasBrush.value.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
1088
1347
  } else {
1089
- canvasBrush.draw(
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
- canvasBrush.getCanvas().paint();
1359
+ activeCanvasBrush.value.getCanvas().paint();
1101
1360
  };
1102
1361
 
1103
1362
  const handleBrushMove = (e: any) => {
1104
- if (!isDrawing.value || !canvasBrush || !imageBox) return;
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
- canvasBrush.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
1373
+ activeCanvasBrush.value.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
1115
1374
  } else {
1116
- canvasBrush.draw(
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
- canvasBrush.getCanvas().paint();
1386
+ activeCanvasBrush.value.getCanvas().paint();
1128
1387
  };
1129
1388
 
1130
1389
  const handleBrushUp = () => {
1131
1390
  isDrawing.value = false;
1132
1391
 
1133
- // 如果有保存的快照,创建撤销命令
1134
- if (commandManager && canvasBrush && brushSnapshotBeforeDraw) {
1135
- const snapshotCommand = new BrushSnapshotCommand(canvasBrush, brushSnapshotBeforeDraw);
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
- canvasBrush?.resetLastPoint();
1401
+ activeCanvasBrush.value?.resetLastPoint();
1142
1402
  };
1143
1403
 
1144
1404
  // 生成 UUID
1145
- const generateUUID = (): string => {
1146
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
1147
- const r = Math.random() * 16 | 0;
1148
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
1149
- return v.toString(16);
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
- // console.log(e)
1184
- if (e.value) {
1185
- if (Array.isArray(e.value)) {
1186
- e.value.forEach((element: { circle: { set: (arg0: { fill: string; stroke: string; }) => void; }; }) => {
1187
- if (!element.circle) return
1188
- element.circle.set({
1189
- fill: pointStyle.value.selectedCircleFill,
1190
- stroke: pointStyle.value.selectedCircleStroke
1191
- })
1192
- });
1193
- } else {
1194
- const _target = e.value.circle || e.value.parent.circle
1195
- if (!_target) return
1196
- _target.set({
1197
- fill: pointStyle.value.selectedCircleFill,
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 label = `#${pointCounter.value}`;
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
- if (selected.length === 0 && (pointAnnotations.value.length > 0 || canvasBrush?.hasContent())) {
1287
- if (confirm('确定清除所有标注和笔刷绘制区域吗?')) {
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
- canvasBrush?.clear();
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 (canvasBrush) {
1650
+ if (!effectiveEnableBrush.value) return;
1651
+ if (activeCanvasBrush.value) {
1345
1652
  if (commandManager) {
1346
- const beforeSnapshot = canvasBrush.getImageData();
1347
- const snapshotCommand = new BrushSnapshotCommand(canvasBrush, beforeSnapshot, true);
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
- canvasBrush.clear();
1352
- canvasBrush.getCanvas().paint();
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>