@zzalai/leafer-point-annotation 1.0.0

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.
@@ -0,0 +1,1663 @@
1
+ <template>
2
+ <div
3
+ class="point-annotation"
4
+ @focus="isCanvasFocused = true"
5
+ @blur="isCanvasFocused = false"
6
+ @mouseenter="isMouseOverCanvas = true"
7
+ @mouseleave="isMouseOverCanvas = false"
8
+ >
9
+ <!-- 画布容器 -->
10
+ <div ref="canvasContainer" class="canvas-container" tabindex="0">
11
+ <!-- 加载占位 -->
12
+ <div
13
+ v-if="loadStatus === 'loading'"
14
+ class="loading-overlay"
15
+ >
16
+ <div class="gradient-animation"></div>
17
+ <div class="loading-text">图片加载中</div>
18
+ </div>
19
+
20
+ <!-- 错误状态 -->
21
+ <div v-if="loadStatus === 'error'" class="error-overlay">
22
+ <p>加载失败</p>
23
+ <button @click="loadImage()">重试</button>
24
+ </div>
25
+
26
+ <!-- 缩放控制器 -->
27
+ <div class="zoom-controller">
28
+ <button class="zoom-button" title="缩小 (Ctrl+-)" @click="zoomOut">
29
+ <svg
30
+ xmlns="http://www.w3.org/2000/svg"
31
+ width="18"
32
+ height="18"
33
+ viewBox="0 0 24 24"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ stroke-width="2"
37
+ stroke-linecap="round"
38
+ stroke-linejoin="round"
39
+ >
40
+ <line x1="5" y1="12" x2="19" y2="12"></line>
41
+ </svg>
42
+ <span class="hotkey-hint" v-if="showHotkeys">Ctrl+-</span>
43
+ </button>
44
+ <div
45
+ class="zoom-value"
46
+ @click="resetZoom"
47
+ title="点击重置为100% (Ctrl+0)"
48
+ >
49
+ {{ zoomLevel }}%
50
+ <span class="hotkey-hint" v-if="showHotkeys">Ctrl+0</span>
51
+ </div>
52
+ <button class="zoom-button" title="放大 (Ctrl++)" @click="zoomIn">
53
+ <svg
54
+ xmlns="http://www.w3.org/2000/svg"
55
+ width="18"
56
+ height="18"
57
+ viewBox="0 0 24 24"
58
+ fill="none"
59
+ stroke="currentColor"
60
+ stroke-width="2"
61
+ stroke-linecap="round"
62
+ stroke-linejoin="round"
63
+ >
64
+ <line x1="12" y1="5" x2="12" y2="19"></line>
65
+ <line x1="5" y1="12" x2="19" y2="12"></line>
66
+ </svg>
67
+ <span class="hotkey-hint" v-if="showHotkeys">Ctrl++</span>
68
+ </button>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- 工具栏 -->
73
+ <div class="toolbar">
74
+ <button
75
+ class="tool-button"
76
+ :class="{ active: currentTool === 'select' }"
77
+ title="选择工具 (V)"
78
+ @click="selectTool"
79
+ >
80
+ <svg
81
+ xmlns="http://www.w3.org/2000/svg"
82
+ width="20"
83
+ height="20"
84
+ viewBox="0 0 24 24"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ stroke-width="2"
88
+ stroke-linecap="round"
89
+ stroke-linejoin="round"
90
+ class="lucide lucide-mouse-pointer2-icon lucide-mouse-pointer-2"
91
+ >
92
+ <path
93
+ d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"
94
+ />
95
+ </svg>
96
+ <span class="hotkey-hint" v-if="showHotkeys">V</span>
97
+ </button>
98
+ <button
99
+ class="tool-button"
100
+ :class="{ active: currentTool === 'point' }"
101
+ title="点标注工具 (P)"
102
+ @click="pointTool"
103
+ >
104
+ <svg
105
+ xmlns="http://www.w3.org/2000/svg"
106
+ width="20"
107
+ height="20"
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ stroke-width="2"
112
+ stroke-linecap="round"
113
+ stroke-linejoin="round"
114
+ >
115
+ <circle cx="12" cy="12" r="10"></circle>
116
+ <circle cx="12" cy="12" r="3"></circle>
117
+ </svg>
118
+ <span class="hotkey-hint" v-if="showHotkeys">P</span>
119
+ </button>
120
+ <button
121
+ class="tool-button"
122
+ :class="{ active: currentTool === 'brush' }"
123
+ title="笔刷工具 (B)"
124
+ @click="brushTool"
125
+ ref="brushButtonRef"
126
+ >
127
+ <svg
128
+ xmlns="http://www.w3.org/2000/svg"
129
+ width="20"
130
+ height="20"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ stroke-width="2"
135
+ stroke-linecap="round"
136
+ stroke-linejoin="round"
137
+ >
138
+ <path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"></path>
139
+ <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>
140
+ </svg>
141
+ <span class="hotkey-hint" v-if="showHotkeys">B</span>
142
+ </button>
143
+ <button
144
+ class="tool-button"
145
+ :class="{ active: currentTool === 'eraser' }"
146
+ title="橡皮擦工具 (E)"
147
+ @click="eraserTool"
148
+ >
149
+ <svg
150
+ xmlns="http://www.w3.org/2000/svg"
151
+ width="20"
152
+ height="20"
153
+ viewBox="0 0 24 24"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ stroke-width="2"
157
+ stroke-linecap="round"
158
+ stroke-linejoin="round"
159
+ >
160
+ <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>
161
+ </svg>
162
+ <span class="hotkey-hint" v-if="showHotkeys">E</span>
163
+ </button>
164
+ <!-- 笔刷大小调整 -->
165
+ <!-- <div v-if="currentTool === 'brush' || currentTool === 'eraser'" class="size-control">
166
+ <div class="size-label">大小</div>
167
+ <input
168
+ type="range"
169
+ class="size-slider"
170
+ :min="localBrushStyle.minSize"
171
+ :max="localBrushStyle.maxSize"
172
+ v-model="localBrushStyle.size"
173
+ />
174
+ <div class="size-value">{{ localBrushStyle.size }}</div>
175
+ </div> -->
176
+ <button class="tool-button" title="撤销 (Ctrl+Z)" @click="undo">
177
+ <svg
178
+ xmlns="http://www.w3.org/2000/svg"
179
+ width="20"
180
+ height="20"
181
+ viewBox="0 0 24 24"
182
+ fill="none"
183
+ stroke="currentColor"
184
+ stroke-width="2"
185
+ stroke-linecap="round"
186
+ stroke-linejoin="round"
187
+ >
188
+ <path d="M3 7v6h6"></path>
189
+ <path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path>
190
+ </svg>
191
+ <span class="hotkey-hint" v-if="showHotkeys">Ctrl+Z</span>
192
+ </button>
193
+ <button class="tool-button" title="重做 (Ctrl+Y)" @click="redo">
194
+ <svg
195
+ xmlns="http://www.w3.org/2000/svg"
196
+ width="20"
197
+ height="20"
198
+ viewBox="0 0 24 24"
199
+ fill="none"
200
+ stroke="currentColor"
201
+ stroke-width="2"
202
+ stroke-linecap="round"
203
+ stroke-linejoin="round"
204
+ >
205
+ <path d="M21 7v6h-6"></path>
206
+ <path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path>
207
+ </svg>
208
+ <span class="hotkey-hint" v-if="showHotkeys">Ctrl+Y</span>
209
+ </button>
210
+ <button class="tool-button" title="删除 (Delete)" @click="deleteSelected">
211
+ <svg
212
+ xmlns="http://www.w3.org/2000/svg"
213
+ width="20"
214
+ height="20"
215
+ viewBox="0 0 24 24"
216
+ fill="none"
217
+ stroke="currentColor"
218
+ stroke-width="2"
219
+ stroke-linecap="round"
220
+ stroke-linejoin="round"
221
+ >
222
+ <path d="M3 6h18"></path>
223
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
224
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
225
+ </svg>
226
+ <span class="hotkey-hint" v-if="showHotkeys">Del</span>
227
+ </button>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- 笔刷样式配置面板 -->
232
+ <BrushStylePanel
233
+ :visible="showBrushPanel"
234
+ :brush-style="localBrushStyle"
235
+ :button-rect="brushButtonRect"
236
+ @close="closeBrushPanel"
237
+ @update="updateBrushStyle"
238
+ />
239
+ </template>
240
+
241
+ <script setup lang="ts">
242
+ import { ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue";
243
+ import {
244
+ App,
245
+ ImageEvent,
246
+ PointerEvent,
247
+ ZoomEvent,
248
+ Image,
249
+ Group,
250
+ } from "leafer-ui";
251
+ import "@leafer-in/editor";
252
+ import "@leafer-in/resize";
253
+ import "@leafer-in/viewport";
254
+ import "@leafer-in/view";
255
+ import { EditorEvent } from '@leafer-in/editor'
256
+ import { CommandManager } from '@zzalai/leafer-undo-redo'
257
+ import { AddPointCommand, RemovePointCommand } from '@/utils/PointCommands';
258
+ import { BrushSnapshotCommand } from '@/utils/BrushCommands';
259
+ import { exportCOCOFormat } from '@/utils/COCOExporter';
260
+ import { exportYOLOFormat } from '@/utils/YOLOExporter';
261
+
262
+ // @ts-ignore - tinykeys 类型声明问题
263
+ import { tinykeys } from "tinykeys";
264
+
265
+ import { PointAnnotationElement } from "@/elements/PointAnnotationElement";
266
+ import { CanvasBrush } from "@/utils/CanvasBrush";
267
+ import BrushStylePanel from "./BrushStylePanel.vue";
268
+ import type { PointAnnotation, PointStyle, BrushStyle } from "@/types";
269
+ import { DEFAULT_POINT_STYLE, DEFAULT_BRUSH_STYLE } from "@/types";
270
+
271
+ // Props
272
+ export interface ImageSource {
273
+ id?: string;
274
+ url: string;
275
+ }
276
+
277
+ export interface OptionsSource {
278
+ pointStyle?: {
279
+ fill: string;
280
+ stroke: string;
281
+ strokeWidth: number;
282
+ width: number;
283
+ height: number;
284
+ };
285
+ brushStyle?: BrushStyle;
286
+ selectedPointStyle?: {
287
+ fill: string;
288
+ stroke: string;
289
+ strokeWidth?: number;
290
+ };
291
+ maxPoints?: number;
292
+ maxUndoSteps?: number;
293
+ maskExportFormat?: 'png' | 'jpeg' | 'jpg';
294
+ maskExportForeground?: 'black' | 'white';
295
+ }
296
+
297
+ const props = defineProps({
298
+ imageSource: {
299
+ type: Object as () => ImageSource,
300
+ required: true,
301
+ },
302
+ options: {
303
+ type: Object as () => OptionsSource,
304
+ default: () => ({}),
305
+ },
306
+ });
307
+
308
+ const emit = defineEmits([
309
+ "pointChange",
310
+ "loadStart",
311
+ "loadSuccess",
312
+ "loadError",
313
+ "undoStateChange",
314
+ "redoStateChange",
315
+ ]);
316
+
317
+ const canvasContainer = ref<HTMLElement | undefined>(undefined);
318
+ const loadStatus = ref<"idle" | "loading" | "success" | "error">("idle");
319
+ const imageWidth = ref<number | null>(null);
320
+ const imageHeight = ref<number | null>(null);
321
+ let app: App | null = null;
322
+ let imageBox: Image | null = null;
323
+ const contentLayer = new Group({ name: "contentLayer" });
324
+ const pointLayer = new Group({ name: "pointLayer" });
325
+
326
+ const mousePosition = ref({ x: 0, y: 0 });
327
+ const isCanvasFocused = ref(false);
328
+ const isMouseOverCanvas = ref(false);
329
+ const showHotkeys = ref(false);
330
+ const currentTool = ref<"select" | "point" | "brush" | "eraser">("select");
331
+ const zoomLevel = ref<number>(100);
332
+
333
+ // 笔刷配置面板相关
334
+ const brushButtonRef = ref<HTMLElement | undefined>(undefined);
335
+ const showBrushPanel = ref(false);
336
+ const brushButtonRect = ref<DOMRect | null>(null);
337
+
338
+ // 点标注数据
339
+ const pointAnnotations = ref<PointAnnotation[]>([]);
340
+ const pointCounter = ref(1);
341
+
342
+ // 点标注样式配置
343
+ const pointStyle = computed<PointStyle>(() => ({
344
+ ...DEFAULT_POINT_STYLE,
345
+ ...props.options?.pointStyle,
346
+ }));
347
+
348
+ // 笔刷样式配置
349
+ const brushStyle = computed<BrushStyle>(() => ({
350
+ ...DEFAULT_BRUSH_STYLE,
351
+ ...props.options?.brushStyle,
352
+ }));
353
+
354
+ // 本地响应式笔刷状态(用于滑块调整)
355
+ const localBrushStyle = ref<BrushStyle>({
356
+ ...DEFAULT_BRUSH_STYLE,
357
+ ...props.options?.brushStyle,
358
+ });
359
+
360
+ // 监听 prop 变化,同步到本地状态
361
+ watch(brushStyle, (newVal: BrushStyle) => {
362
+ localBrushStyle.value = { ...newVal };
363
+ }, { immediate: true });
364
+
365
+ // 监听笔刷透明度变化,更新 CanvasBrush 的 Group 透明度
366
+ watch(
367
+ () => localBrushStyle.value.opacity,
368
+ (newOpacity) => {
369
+ if (canvasBrush) {
370
+ canvasBrush.setOpacity(newOpacity);
371
+ }
372
+ }
373
+ );
374
+
375
+ // 笔刷相关状态
376
+ let canvasBrush: CanvasBrush | null = null;
377
+ const isDrawing = ref(false);
378
+
379
+ // 撤销/重做管理器
380
+ let commandManager: CommandManager | null = null;
381
+
382
+ // 根据配置强制【标注点】不跟随画布Scale变化
383
+ const changePointScaleRelativeCanvas = (pointAnnotationLayer: Group | null) => {
384
+ // 检查是否开启固定大小功能
385
+ if (!pointStyle.value.fixedSizeOnZoom) return;
386
+
387
+ if (pointAnnotationLayer && pointAnnotationLayer.children && pointAnnotationLayer.children.length) {
388
+ const _scaleX = app?.tree.scaleX || 1;
389
+ const scaleFactor = pointStyle.value.fixedSizeScale || 1;
390
+ pointAnnotationLayer.children.forEach(element => {
391
+ element.scale = scaleFactor / _scaleX;
392
+ });
393
+ }
394
+ }
395
+
396
+ const initCanvas = () => {
397
+ app = new App({
398
+ view: canvasContainer.value,
399
+ width: canvasContainer.value?.clientWidth || 800,
400
+ height: canvasContainer.value?.clientHeight || 600,
401
+ fill: "#e3e3e3",
402
+ zoom: { min: 0.2, max: 4 },
403
+ editor: {
404
+ rotateable: false,
405
+ middlePoint: {},
406
+ },
407
+ tree: {
408
+ type: "design",
409
+ },
410
+ });
411
+
412
+ app?.tree.add(contentLayer);
413
+ app?.tree.add(pointLayer);
414
+
415
+ // 设置图层的 zIndex
416
+ pointLayer.zIndex = 10000;
417
+
418
+ if (app) {
419
+ app.on(ZoomEvent.ZOOM, () => {
420
+ updateZoomLevel();
421
+ });
422
+
423
+ // 监听画布点击事件,用于创建点标注
424
+ app.on(PointerEvent.TAP, handleCanvasTap);
425
+ app.editor.on(EditorEvent.SELECT, handlePointAnnotationSelected);
426
+
427
+ // 笔刷绘制事件
428
+ app.on(PointerEvent.DOWN, handleBrushDown);
429
+ app.on(PointerEvent.MOVE, handleBrushMove);
430
+ app.on(PointerEvent.UP, handleBrushUp);
431
+ }
432
+ };
433
+
434
+ const preloadImageSize = (
435
+ url: string,
436
+ ): Promise<{ width: number; height: number }> => {
437
+ return new Promise((resolve, reject) => {
438
+ const tempImage = new window.Image();
439
+ tempImage.onload = () => {
440
+ resolve({
441
+ width: tempImage.width,
442
+ height: tempImage.height,
443
+ });
444
+ };
445
+ tempImage.onerror = reject;
446
+ tempImage.src = url;
447
+ });
448
+ };
449
+
450
+ const loadImage = async (imageSrc?: string | undefined) => {
451
+ const _imageSrc = imageSrc ? imageSrc : props.imageSource.url;
452
+ if (!app || !_imageSrc) return;
453
+
454
+ if (imageBox) {
455
+ contentLayer.clear();
456
+ imageBox.destroy();
457
+ }
458
+
459
+ loadStatus.value = "loading";
460
+ emit("loadStart");
461
+ imageWidth.value = null;
462
+ imageHeight.value = null;
463
+
464
+ try {
465
+ const size = await preloadImageSize(_imageSrc);
466
+ imageWidth.value = size.width;
467
+ imageHeight.value = size.height;
468
+
469
+ imageBox = new Image({
470
+ url: _imageSrc,
471
+ draggable: false,
472
+ editable: false,
473
+ lazy: true,
474
+ zIndex: -1,
475
+ placeholderColor: "transparent",
476
+ });
477
+
478
+ imageBox.on(ImageEvent.LOADED, function () {
479
+ loadStatus.value = "success";
480
+ emit("loadSuccess");
481
+ fitImageToCanvas();
482
+ initBrushLayer();
483
+ });
484
+
485
+ imageBox.on(ImageEvent.ERROR, function (e: ImageEvent) {
486
+ loadStatus.value = "error";
487
+ emit("loadError", e);
488
+ console.error("Failed to load image:", e);
489
+ });
490
+
491
+ contentLayer.add(imageBox);
492
+ } catch (error) {
493
+ loadStatus.value = "error";
494
+ emit("loadError", error);
495
+ console.error("Failed to preload image size:", error);
496
+ }
497
+ };
498
+
499
+ // 点标注数据结构(内部使用)
500
+
501
+
502
+ const getImageInfo = () => {
503
+ return {
504
+ id: props.imageSource.id,
505
+ url: props.imageSource.url,
506
+ width: imageWidth.value,
507
+ height: imageHeight.value,
508
+ };
509
+ };
510
+
511
+ const exportCanvasJSON = (): string => {
512
+ const exportData = {
513
+ version: '1.0',
514
+ imageUrl: props.imageSource.url || '',
515
+ imageWidth: imageWidth.value,
516
+ imageHeight: imageHeight.value,
517
+ pointAnnotations: [...pointAnnotations.value],
518
+ brushMask: canvasBrush?.getImageData() || null,
519
+ exportTime: Date.now(),
520
+ };
521
+ return JSON.stringify(exportData, null, 2);
522
+ };
523
+
524
+ // 导出二值图(Mask)
525
+ const exportMaskImage = (format?: 'png' | 'jpeg' | 'jpg', foregroundColor?: 'black' | 'white'): Promise<string | null> => {
526
+ return new Promise((resolve) => {
527
+ if (!canvasBrush) {
528
+ resolve(null);
529
+ return;
530
+ }
531
+
532
+ const exportFormat = format || props.options?.maskExportFormat || 'png';
533
+ const fgColor = foregroundColor || props.options?.maskExportForeground || 'black';
534
+
535
+ const maskData = canvasBrush.getImageData();
536
+ if (!maskData) {
537
+ resolve(null);
538
+ return;
539
+ }
540
+
541
+ const htmlImg = document.createElement('img');
542
+ htmlImg.onload = () => {
543
+ const canvas = document.createElement('canvas');
544
+ canvas.width = imageWidth.value || 0;
545
+ canvas.height = imageHeight.value || 0;
546
+ const ctx = canvas.getContext('2d');
547
+ if (!ctx) {
548
+ resolve(null);
549
+ return;
550
+ }
551
+
552
+ ctx.drawImage(htmlImg, 0, 0);
553
+
554
+ const isWhite = fgColor === 'white';
555
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
556
+ const data = imageData.data;
557
+
558
+ for (let i = 0; i < data.length; i += 4) {
559
+ if (data[i + 3] > 0) {
560
+ data[i] = isWhite ? 255 : 0;
561
+ data[i + 1] = isWhite ? 255 : 0;
562
+ data[i + 2] = isWhite ? 255 : 0;
563
+ data[i + 3] = 255;
564
+ } else if (exportFormat !== 'png') {
565
+ data[i] = isWhite ? 0 : 255;
566
+ data[i + 1] = isWhite ? 0 : 255;
567
+ data[i + 2] = isWhite ? 0 : 255;
568
+ data[i + 3] = 255;
569
+ }
570
+ }
571
+ ctx.putImageData(imageData, 0, 0);
572
+
573
+ if (exportFormat === 'png') {
574
+ resolve(canvas.toDataURL('image/png'));
575
+ } else {
576
+ resolve(canvas.toDataURL('image/jpeg', 0.95));
577
+ }
578
+ };
579
+
580
+ htmlImg.onerror = () => {
581
+ resolve(null);
582
+ };
583
+
584
+ htmlImg.src = maskData;
585
+ });
586
+ };
587
+
588
+ const exportCOCO = (): string => {
589
+ const coco = exportCOCOFormat(
590
+ pointAnnotations.value,
591
+ props.imageSource.url || '',
592
+ imageWidth.value || 0,
593
+ imageHeight.value || 0
594
+ );
595
+ return JSON.stringify(coco, null, 2);
596
+ };
597
+
598
+ const exportYOLO = (): { annotations: string; classNames: string } => {
599
+ const yolo = exportYOLOFormat(
600
+ pointAnnotations.value,
601
+ imageWidth.value || 0,
602
+ imageHeight.value || 0
603
+ );
604
+ return {
605
+ annotations: yolo.annotations,
606
+ classNames: yolo.classNames,
607
+ };
608
+ };
609
+
610
+ const importCanvasJSON = async (
611
+ jsonString: string,
612
+ options?: { resetZoom?: boolean },
613
+ ): Promise<boolean> => {
614
+ try {
615
+ const data = JSON.parse(jsonString);
616
+
617
+ // 如果指定了重置缩放
618
+ if (options?.resetZoom) {
619
+ resetZoom();
620
+ fitImageToCanvas();
621
+ }
622
+
623
+ // 清除现有标注
624
+ pointAnnotations.value.forEach((p: any) => {
625
+ const element = pointLayer.findOne(`#${p.id}`);
626
+ if (element) {
627
+ pointLayer.remove(element);
628
+ element.destroy();
629
+ }
630
+ });
631
+ pointAnnotations.value = [];
632
+
633
+ // 清除笔刷
634
+ canvasBrush?.clear();
635
+
636
+ // 如果有图片URL且与当前不同,加载图片
637
+ if (data.imageUrl && data.imageUrl !== props.imageSource.url) {
638
+ await loadImage(data.imageUrl);
639
+ }
640
+
641
+ // 恢复点标注
642
+ if (data.pointAnnotations && Array.isArray(data.pointAnnotations)) {
643
+ for (const pointData of data.pointAnnotations) {
644
+ const pointElement = new PointAnnotationElement(pointData, pointStyle.value);
645
+ if (currentTool.value === 'brush' || currentTool.value === 'eraser') {
646
+ pointElement.label.editable = false;
647
+ }
648
+ pointLayer.add(pointElement);
649
+ pointAnnotations.value.push(pointData);
650
+ }
651
+ }
652
+
653
+ // 恢复笔刷遮罩
654
+ if (data.brushMask && canvasBrush) {
655
+ canvasBrush.restoreImageData(data.brushMask);
656
+ }
657
+
658
+ // 强制【标注点】不跟随画布Scale变化
659
+ changePointScaleRelativeCanvas(pointLayer);
660
+
661
+ return true;
662
+ } catch (error) {
663
+ console.error('Failed to import canvas JSON:', error);
664
+ return false;
665
+ }
666
+ };
667
+
668
+ onMounted(() => {
669
+ nextTick(() => {
670
+ initCanvas();
671
+ loadImage();
672
+
673
+ // 初始化撤销/重做管理器
674
+ commandManager = new CommandManager(100);
675
+
676
+ window.addEventListener("keydown", handleKeyDown);
677
+ window.addEventListener("mousemove", handleMouseMove);
678
+ window.addEventListener("focusout", handleFocusOut);
679
+
680
+ const unsubscribe = tinykeys(window, {
681
+ v: (event: KeyboardEvent) => {
682
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
683
+ event.preventDefault();
684
+ selectTool();
685
+ },
686
+ p: (event: KeyboardEvent) => {
687
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
688
+ event.preventDefault();
689
+ pointTool();
690
+ },
691
+ b: (event: KeyboardEvent) => {
692
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
693
+ event.preventDefault();
694
+ brushTool();
695
+ },
696
+ e: (event: KeyboardEvent) => {
697
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
698
+ event.preventDefault();
699
+ eraserTool();
700
+ },
701
+ "$mod+KeyZ": (event: KeyboardEvent) => {
702
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
703
+ event.preventDefault();
704
+ event.stopPropagation();
705
+ undo();
706
+ },
707
+ "$mod+KeyY": (event: KeyboardEvent) => {
708
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
709
+ event.preventDefault();
710
+ event.stopPropagation();
711
+ redo();
712
+ },
713
+ Delete: (event: KeyboardEvent) => {
714
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
715
+ event.preventDefault();
716
+ event.stopPropagation();
717
+ deleteSelected();
718
+ },
719
+ "$mod+Equal": (event: KeyboardEvent) => {
720
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
721
+ event.preventDefault();
722
+ event.stopPropagation();
723
+ zoomIn();
724
+ },
725
+ "$mod+Minus": (event: KeyboardEvent) => {
726
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
727
+ event.preventDefault();
728
+ event.stopPropagation();
729
+ zoomOut();
730
+ },
731
+ "$mod+0": (event: KeyboardEvent) => {
732
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
733
+ event.preventDefault();
734
+ event.stopPropagation();
735
+ resetZoom();
736
+ },
737
+ Alt: (event: KeyboardEvent) => {
738
+ if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
739
+ event.preventDefault();
740
+ showHotkeys.value = !showHotkeys.value;
741
+ },
742
+ });
743
+
744
+ window.__pointAnnotationHotkeysUnsubscribe = unsubscribe;
745
+ });
746
+ });
747
+
748
+ const handleMouseMove = (e: MouseEvent) => {
749
+ mousePosition.value = {
750
+ x: e.clientX,
751
+ y: e.clientY,
752
+ };
753
+ };
754
+
755
+ const fitImageToCanvas = () => {
756
+ if (!app || !imageBox || !imageWidth.value || !imageHeight.value) return;
757
+
758
+ const canvasWidth = app.width as number;
759
+ const canvasHeight = app.height as number;
760
+ const imageWidthVal = imageWidth.value;
761
+ const imageHeightVal = imageHeight.value;
762
+
763
+ const scaleX = canvasWidth / imageWidthVal;
764
+ const scaleY = canvasHeight / imageHeightVal;
765
+ const scale = Math.min(scaleX, scaleY, 1);
766
+
767
+ const centerX = (canvasWidth - imageWidthVal * Number(scale.toFixed(2))) / 2;
768
+ const centerY = (canvasHeight - imageHeightVal * Number(scale.toFixed(2))) / 2;
769
+
770
+ app.tree.scale = Number(scale.toFixed(2));
771
+ app.tree.x = centerX;
772
+ app.tree.y = centerY;
773
+ updateZoomLevel();
774
+ };
775
+
776
+ const isMouseInCanvas = (): boolean => {
777
+ if (!canvasContainer.value) return false;
778
+
779
+ const rect = canvasContainer.value.getBoundingClientRect();
780
+
781
+ return (
782
+ mousePosition.value.x >= rect.left &&
783
+ mousePosition.value.x <= rect.right &&
784
+ mousePosition.value.y >= rect.top &&
785
+ mousePosition.value.y <= rect.bottom
786
+ );
787
+ };
788
+
789
+ const handleKeyDown = (e: KeyboardEvent) => {
790
+ if (e.code === "Space") {
791
+ if (isMouseInCanvas()) {
792
+ e.preventDefault();
793
+ return false;
794
+ }
795
+ }
796
+ };
797
+ const handleFocusOut = (e: FocusEvent) => {
798
+ const target = e.target;
799
+ if (target instanceof HTMLElement && target.classList[0] === 'leafer-text-editor') {
800
+ // 关键:手动触发 DOM 原生的 blur
801
+ // 这会强制让浏览器完成失焦流程,触发插件的销毁逻辑
802
+ app?.editor.cancel()
803
+ // console.log('已强制执行 blur');
804
+ }
805
+ };
806
+
807
+ const updateLabelEditable = (editable: boolean) => {
808
+ // 遍历所有点标注元素,更新标签的可编辑状态
809
+ if (pointLayer && pointLayer.children) {
810
+ pointLayer.children.forEach((element: any) => {
811
+ if (element._element_tag === 'point-annotation' && element.label) {
812
+ element.label.editable = editable;
813
+ // if(!editable) app?.editor.cancel()
814
+ }
815
+ });
816
+ }
817
+ };
818
+
819
+
820
+ onUnmounted(() => {
821
+ if (imageBox) {
822
+ app?.tree.remove(imageBox);
823
+ imageBox = null;
824
+ }
825
+ app?.destroy();
826
+ app = null;
827
+
828
+ window.removeEventListener("keydown", handleKeyDown);
829
+ window.removeEventListener("mousemove", handleMouseMove);
830
+ window.removeEventListener('focusout', handleFocusOut);
831
+
832
+ if (window.__pointAnnotationHotkeysUnsubscribe) {
833
+ window.__pointAnnotationHotkeysUnsubscribe();
834
+ delete window.__pointAnnotationHotkeysUnsubscribe;
835
+ }
836
+ });
837
+
838
+ // 工具切换函数
839
+ const selectTool = () => {
840
+ currentTool.value = "select";
841
+ // 关闭笔刷配置面板
842
+ showBrushPanel.value = false;
843
+ if (!app) return
844
+ app.editor.config.moveable = false
845
+ app.editor.config.resizeable = false
846
+ app.editor.config.multipleSelect = true // 启用多选
847
+ // 禁用笔刷 Canvas 的点击事件,让点击穿透到图片
848
+ canvasBrush?.setPointerEvents(false);
849
+ // 启用标签编辑
850
+ updateLabelEditable(true);
851
+ };
852
+
853
+ const pointTool = () => {
854
+ currentTool.value = "point";
855
+ // 关闭笔刷配置面板
856
+ showBrushPanel.value = false;
857
+ if (!app) return
858
+ app.editor.config.moveable = true
859
+ app.editor.config.multipleSelect = false // 禁用多选
860
+ // 禁用笔刷 Canvas 的点击事件,让点击穿透到图片
861
+ canvasBrush?.setPointerEvents(false);
862
+ // 启用标签编辑
863
+ updateLabelEditable(true);
864
+ };
865
+
866
+ const brushTool = () => {
867
+ currentTool.value = "brush";
868
+ if (!app) return;
869
+ // 切换到笔刷模式时禁用编辑器
870
+ app.editor.config.moveable = false;
871
+ app.editor.config.resizeable = false;
872
+ app.editor.config.multipleSelect = false;
873
+ // 启用笔刷 Canvas 的点击事件
874
+ canvasBrush?.setPointerEvents(true);
875
+ // 禁用标签编辑
876
+ updateLabelEditable(false);
877
+ // 显示配置面板
878
+ showBrushPanel.value = !showBrushPanel.value;
879
+ if (showBrushPanel.value) {
880
+ // 获取按钮位置
881
+ nextTick(() => {
882
+ if (brushButtonRef.value) {
883
+ brushButtonRect.value = brushButtonRef.value.getBoundingClientRect();
884
+ }
885
+ });
886
+ }
887
+ };
888
+
889
+ // 关闭笔刷配置面板
890
+ const closeBrushPanel = () => {
891
+ showBrushPanel.value = false;
892
+ };
893
+
894
+ // 更新笔刷样式
895
+ const updateBrushStyle = (style: Partial<BrushStyle>) => {
896
+ Object.assign(localBrushStyle.value, style);
897
+ };
898
+
899
+ const eraserTool = () => {
900
+ currentTool.value = "eraser";
901
+ // 关闭笔刷配置面板
902
+ showBrushPanel.value = false;
903
+ if (!app) return;
904
+ // 切换到擦除模式时禁用编辑器
905
+ app.editor.config.moveable = false;
906
+ app.editor.config.resizeable = false;
907
+ app.editor.config.multipleSelect = false;
908
+ // 启用笔刷 Canvas 的点击事件
909
+ canvasBrush?.setPointerEvents(true);
910
+ // 禁用标签编辑
911
+ updateLabelEditable(false);
912
+ };
913
+
914
+ // 初始化笔刷图层(在图片加载后调用)
915
+ const initBrushLayer = () => {
916
+ if (!imageWidth.value || !imageHeight.value || !app) return;
917
+
918
+ // 清除旧的笔刷
919
+ if (canvasBrush) {
920
+ canvasBrush.getGroup().remove();
921
+ }
922
+
923
+ // 创建新的 CanvasBrush 实例
924
+ canvasBrush = new CanvasBrush(
925
+ imageWidth.value,
926
+ imageHeight.value,
927
+ localBrushStyle.value
928
+ );
929
+
930
+ // 将 LeaferJS Group 添加到 contentLayer
931
+ contentLayer.add(canvasBrush.getGroup());
932
+ };
933
+
934
+ // 笔刷绘制事件处理
935
+ let brushSnapshotBeforeDraw: string | null = null;
936
+
937
+ const handleBrushDown = (e: any) => {
938
+ if (currentTool.value !== 'brush' && currentTool.value !== 'eraser') return;
939
+ if (!app || !imageBox || !canvasBrush) return;
940
+
941
+ isDrawing.value = true;
942
+
943
+ // 保存当前画布快照(用于撤销)
944
+ if (commandManager) {
945
+ brushSnapshotBeforeDraw = canvasBrush.getImageData();
946
+ }
947
+
948
+ // 获取相对于图片的坐标(与点标注相同的方式)
949
+ const point = contentLayer.getBoxPoint({ x: e.x, y: e.y });
950
+
951
+ // 判断是否为擦除模式
952
+ const isErase = currentTool.value === 'eraser';
953
+
954
+ // 根据模式绘制
955
+ if (isErase) {
956
+ canvasBrush.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
957
+ } else {
958
+ canvasBrush.draw(
959
+ point.x,
960
+ point.y,
961
+ localBrushStyle.value.size,
962
+ localBrushStyle.value.color,
963
+ localBrushStyle.value.opacity,
964
+ localBrushStyle.value.continuity
965
+ );
966
+ }
967
+
968
+ // 触发 Canvas 重绘
969
+ canvasBrush.getCanvas().paint();
970
+ };
971
+
972
+ const handleBrushMove = (e: any) => {
973
+ if (!isDrawing.value || !canvasBrush || !imageBox) return;
974
+
975
+ // 获取相对于图片的坐标(与点标注相同的方式)
976
+ const point = contentLayer.getBoxPoint({ x: e.x, y: e.y });
977
+
978
+ // 判断是否为擦除模式
979
+ const isErase = currentTool.value === 'eraser';
980
+
981
+ // 根据模式绘制
982
+ if (isErase) {
983
+ canvasBrush.erase(point.x, point.y, localBrushStyle.value.size, localBrushStyle.value.continuity);
984
+ } else {
985
+ canvasBrush.draw(
986
+ point.x,
987
+ point.y,
988
+ localBrushStyle.value.size,
989
+ localBrushStyle.value.color,
990
+ localBrushStyle.value.opacity,
991
+ localBrushStyle.value.continuity
992
+ );
993
+ }
994
+
995
+ // 触发 Canvas 重绘
996
+ canvasBrush.getCanvas().paint();
997
+ };
998
+
999
+ const handleBrushUp = () => {
1000
+ isDrawing.value = false;
1001
+
1002
+ // 如果有保存的快照,创建撤销命令
1003
+ if (commandManager && canvasBrush && brushSnapshotBeforeDraw) {
1004
+ const snapshotCommand = new BrushSnapshotCommand(canvasBrush, brushSnapshotBeforeDraw);
1005
+ commandManager.executeCommand(snapshotCommand);
1006
+ brushSnapshotBeforeDraw = null;
1007
+ }
1008
+
1009
+ // 重置上一个点,避免下次绘制时从上次结束的地方连线
1010
+ canvasBrush?.resetLastPoint();
1011
+ };
1012
+
1013
+ // 生成 UUID
1014
+ const generateUUID = (): string => {
1015
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
1016
+ const r = Math.random() * 16 | 0;
1017
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
1018
+ return v.toString(16);
1019
+ });
1020
+ };
1021
+
1022
+ // 处理画布点击事件
1023
+ const handleCanvasTap = (e: any) => {
1024
+ if (currentTool.value !== 'point' || !app || !imageBox) return;
1025
+
1026
+ // 如果编辑器正处于编辑状态(有点标注被选中),不创建新标注
1027
+ if (app.editor && app.editor.list && app.editor.list.length > 0) return;
1028
+
1029
+ // 检查是否点击在点标注元素上(遍历父级链)
1030
+ let target: any = e.target;
1031
+ while (target) {
1032
+ if (target._element_tag === 'point-annotation') return;
1033
+ target = target.parent;
1034
+ }
1035
+
1036
+ // 获取相对于 contentLayer 的坐标
1037
+ const point = contentLayer.getBoxPoint({ x: e.x, y: e.y });
1038
+
1039
+ // 检查是否在图片范围内
1040
+ if (point.x < 0 || point.x > (imageWidth.value || 0) ||
1041
+ point.y < 0 || point.y > (imageHeight.value || 0)) {
1042
+ return;
1043
+ }
1044
+
1045
+ // 创建点标注
1046
+ createPointAnnotation(point.x, point.y);
1047
+ };
1048
+
1049
+ // 处理点击【标注点】选中样式
1050
+ const handlePointAnnotationSelected = (e: any) => {
1051
+ if (currentTool.value === 'brush' || currentTool.value === 'eraser' || !app || !imageBox) return;
1052
+ // console.log(e)
1053
+ if (e.value) {
1054
+ if (Array.isArray(e.value)) {
1055
+ e.value.forEach((element: { circle: { set: (arg0: { fill: string; stroke: string; }) => void; }; }) => {
1056
+ if (!element.circle) return
1057
+ element.circle.set({
1058
+ fill: pointStyle.value.selectedCircleFill,
1059
+ stroke: pointStyle.value.selectedCircleStroke
1060
+ })
1061
+ });
1062
+ } else {
1063
+ const _target = e.value.circle || e.value.parent.circle
1064
+ if (!_target) return
1065
+ _target.set({
1066
+ fill: pointStyle.value.selectedCircleFill,
1067
+ stroke: pointStyle.value.selectedCircleStroke
1068
+ })
1069
+ }
1070
+ }
1071
+ if (e.oldValue && (!Array.isArray(e.oldValue) || !e.value)) {
1072
+ if (Array.isArray(e.oldValue)) {
1073
+ e.oldValue.forEach((element: { circle: { set: (arg0: { fill: string; stroke: string; }) => void; }; }) => {
1074
+ if (!element.circle) return
1075
+ element.circle.set({
1076
+ fill: pointStyle.value.circleFill,
1077
+ stroke: pointStyle.value.circleStroke
1078
+ })
1079
+ });
1080
+ } else {
1081
+ const _target = e.oldValue.circle || e.oldValue.parent.circle
1082
+ if (!_target || (e.oldValue === e.value?.parent)) return
1083
+ _target.set({
1084
+ fill: pointStyle.value.circleFill,
1085
+ stroke: pointStyle.value.circleStroke
1086
+ })
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ // 创建点标注
1092
+ const createPointAnnotation = (pixelX: number, pixelY: number): string | null => {
1093
+ if (!imageWidth.value || !imageHeight.value) return null;
1094
+
1095
+ const id = `point_${generateUUID()}`;
1096
+ const label = `#${pointCounter.value}`;
1097
+
1098
+ // 计算归一化坐标
1099
+ const normalizedX = pixelX / imageWidth.value;
1100
+ const normalizedY = pixelY / imageHeight.value;
1101
+
1102
+ const pointData: PointAnnotation = {
1103
+ id,
1104
+ pixel: { x: pixelX, y: pixelY },
1105
+ normalized: { x: normalizedX, y: normalizedY },
1106
+ label,
1107
+ createdAt: Date.now(),
1108
+ updatedAt: Date.now(),
1109
+ };
1110
+
1111
+ // 创建点标注元素
1112
+ const pointElement = new PointAnnotationElement(pointData, pointStyle.value);
1113
+
1114
+ // 根据当前工具设置标签的可编辑状态
1115
+ if (currentTool.value === 'brush' || currentTool.value === 'eraser') {
1116
+ pointElement.label.editable = false;
1117
+ }
1118
+
1119
+ // 使用命令模式添加到图层
1120
+ if (commandManager) {
1121
+ const addCommand = new AddPointCommand(pointLayer, pointElement, pointAnnotations.value, pointData);
1122
+ commandManager.executeCommand(addCommand);
1123
+ } else {
1124
+ pointLayer.add(pointElement);
1125
+ pointAnnotations.value.push(pointData);
1126
+ }
1127
+
1128
+ // 强制【标注点】不跟随画布Scale变化
1129
+ changePointScaleRelativeCanvas(pointLayer);
1130
+
1131
+ pointCounter.value++;
1132
+
1133
+ // 触发事件
1134
+ emit("pointChange", [...pointAnnotations.value]);
1135
+
1136
+ // 选中新创建的点
1137
+ if (app?.editor) {
1138
+ app.editor.select(pointElement);
1139
+ }
1140
+
1141
+ return id;
1142
+ };
1143
+
1144
+ // 删除选中的点标注或清除笔刷内容
1145
+ const deleteSelected = () => {
1146
+ // 如果当前工具是笔刷,清除所有笔刷内容
1147
+ if (currentTool.value === 'brush' || currentTool.value === 'eraser') {
1148
+ clearBrush();
1149
+ return;
1150
+ }
1151
+
1152
+ // select 模式下未选中任何元素,清除所有
1153
+ if (currentTool.value === 'select') {
1154
+ const selected = app?.editor?.list || [];
1155
+ if (selected.length === 0 && (pointAnnotations.value.length > 0 || canvasBrush?.hasContent())) {
1156
+ if (confirm('确定清除所有标注和笔刷绘制区域吗?')) {
1157
+ clearAllAnnotationsAndBrush();
1158
+ }
1159
+ return;
1160
+ }
1161
+ }
1162
+
1163
+ if (!app?.editor) return;
1164
+
1165
+ const selected = app.editor.list;
1166
+ if (selected.length === 0) return;
1167
+
1168
+ selected.forEach((element: any) => {
1169
+ // 使用 _element_tag 来识别点标注元素
1170
+ if (element._element_tag === 'point-annotation') {
1171
+ // 使用命令模式从图层和数据中移除
1172
+ if (commandManager) {
1173
+ const removeCommand = new RemovePointCommand(pointLayer, element, pointAnnotations.value);
1174
+ commandManager.executeCommand(removeCommand);
1175
+ } else {
1176
+ pointLayer.remove(element);
1177
+ element.destroy();
1178
+ const index = pointAnnotations.value.findIndex(p => p.id === element.data.id);
1179
+ if (index > -1) {
1180
+ pointAnnotations.value.splice(index, 1);
1181
+ }
1182
+ }
1183
+ }
1184
+ });
1185
+
1186
+ // 清除编辑器选择
1187
+ app.editor.cancel();
1188
+
1189
+ // 触发事件
1190
+ emit("pointChange", [...pointAnnotations.value]);
1191
+ };
1192
+
1193
+ // 清除所有标注和笔刷
1194
+ const clearAllAnnotationsAndBrush = () => {
1195
+ // 清除所有点标注
1196
+ pointAnnotations.value.forEach((p: any) => {
1197
+ const element = pointLayer.children.find((el: any) => el.data?.id === p.id);
1198
+ if (element) {
1199
+ pointLayer.remove(element);
1200
+ element.destroy();
1201
+ }
1202
+ });
1203
+ pointAnnotations.value = [];
1204
+
1205
+ // 清除笔刷
1206
+ canvasBrush?.clear();
1207
+
1208
+ emit("pointChange", []);
1209
+ };
1210
+
1211
+ // 清除所有笔刷内容
1212
+ const clearBrush = () => {
1213
+ if (canvasBrush) {
1214
+ if (commandManager) {
1215
+ const beforeSnapshot = canvasBrush.getImageData();
1216
+ const snapshotCommand = new BrushSnapshotCommand(canvasBrush, beforeSnapshot, true);
1217
+ commandManager.executeCommand(snapshotCommand);
1218
+ }
1219
+
1220
+ canvasBrush.clear();
1221
+ canvasBrush.getCanvas().paint();
1222
+ }
1223
+ };
1224
+
1225
+ // 获取点标注数据
1226
+ const getPointAnnotations = (): PointAnnotation[] => {
1227
+ return [...pointAnnotations.value];
1228
+ };
1229
+
1230
+ const undo = () => {
1231
+ if (commandManager?.canUndo()) {
1232
+ commandManager.undo();
1233
+ }
1234
+ };
1235
+
1236
+ const redo = () => {
1237
+ if (commandManager?.canRedo()) {
1238
+ commandManager.redo();
1239
+ }
1240
+ };
1241
+
1242
+ const getCurrentTool = (): "select" | "point" | "brush" | "eraser" => {
1243
+ return currentTool.value;
1244
+ };
1245
+
1246
+ const setTool = (tool: "select" | "point" | "brush" | "eraser") => {
1247
+ currentTool.value = tool;
1248
+ };
1249
+
1250
+ const removePointAnnotation = (id: string): boolean => {
1251
+ const index = pointAnnotations.value.findIndex(p => p.id === id);
1252
+ if (index === -1) return false;
1253
+
1254
+ const element = pointLayer.children[index];
1255
+ if (!element) return false;
1256
+
1257
+ if (commandManager) {
1258
+ const removeCommand = new RemovePointCommand(pointLayer, element as any, pointAnnotations.value);
1259
+ commandManager.executeCommand(removeCommand);
1260
+ } else {
1261
+ pointLayer.remove(element);
1262
+ element.destroy();
1263
+ pointAnnotations.value.splice(index, 1);
1264
+ }
1265
+
1266
+ emit("pointChange", [...pointAnnotations.value]);
1267
+ return true;
1268
+ };
1269
+
1270
+ const zoomOut = () => {
1271
+ if (!app) return;
1272
+ app.tree.zoom("out");
1273
+ updateZoomLevel();
1274
+ };
1275
+
1276
+ const zoomIn = () => {
1277
+ if (!app) return;
1278
+ app.tree.zoom("in");
1279
+ updateZoomLevel();
1280
+ };
1281
+
1282
+ const resetZoom = () => {
1283
+ if (!app) return;
1284
+ app.tree.zoom(1);
1285
+ updateZoomLevel();
1286
+ };
1287
+
1288
+ const updateZoomLevel = () => {
1289
+ if (!app || !app.tree || app.tree.scaleX === undefined) return;
1290
+ zoomLevel.value = Math.round(app.tree.scaleX * 100);
1291
+ // 强制【标注点】不跟随画布Scale变化
1292
+ changePointScaleRelativeCanvas(pointLayer);
1293
+ };
1294
+
1295
+ defineExpose({
1296
+ getPointAnnotations,
1297
+ getImageInfo,
1298
+ exportCanvasJSON,
1299
+ exportMaskImage,
1300
+ exportCOCO,
1301
+ exportYOLO,
1302
+ importCanvasJSON,
1303
+ loadImage,
1304
+ clearBrush,
1305
+ zoomIn,
1306
+ zoomOut,
1307
+ resetZoom,
1308
+ undo,
1309
+ redo,
1310
+ getCurrentTool,
1311
+ setTool,
1312
+ createPointAnnotation,
1313
+ removePointAnnotation,
1314
+ });
1315
+
1316
+ declare global {
1317
+ interface Window {
1318
+ __pointAnnotationHotkeysUnsubscribe?: () => void;
1319
+ }
1320
+ }
1321
+ </script>
1322
+
1323
+ <style>
1324
+ :root {
1325
+ --leafer-point-color-primary: #007aff;
1326
+ --leafer-point-color-background: #f5f5f5;
1327
+ --leafer-point-color-background-light: #f0f0f0;
1328
+ --leafer-point-color-white: #fff;
1329
+ --leafer-point-color-text: #333;
1330
+ --leafer-point-color-text-secondary: #666;
1331
+ --leafer-point-color-text-tertiary: #999999;
1332
+ --leafer-point-color-border: #ddd;
1333
+ --leafer-point-color-border-light: #e0e0e0;
1334
+ --leafer-point-color-error: #e74c3c;
1335
+ --leafer-point-color-button: #3498db;
1336
+ --leafer-point-color-button-hover: #2980b9;
1337
+
1338
+ --leafer-point-padding-toolbar: 10px;
1339
+ --leafer-point-padding-tool-button: 8px;
1340
+ --leafer-point-size-tool-icon: 18px;
1341
+ --leafer-point-size-zoom-button: 36px;
1342
+ --leafer-point-size-zoom-value: 60px;
1343
+ --leafer-point-font-size-hotkey: 10px;
1344
+ --leafer-point-padding-hotkey: 1px 3px;
1345
+ --leafer-point-padding-error: 20px;
1346
+ --leafer-point-padding-error-button: 8px 16px;
1347
+
1348
+ --leafer-point-border-radius-tool-button: 4px;
1349
+ --leafer-point-border-radius-hotkey: 2px;
1350
+ --leafer-point-border-radius-overlay: 8px;
1351
+ --leafer-point-border-radius-zoom: 8px;
1352
+
1353
+ --leafer-point-shadow-tool-button: 0 2px 4px rgba(0, 0, 0, 0.1);
1354
+ --leafer-point-shadow-tool-button-active: 0 2px 4px rgba(0, 122, 255, 0.3);
1355
+ --leafer-point-shadow-tool-button-hover: 0 4px 6px rgba(0, 0, 0, 0.1);
1356
+ --leafer-point-shadow-overlay: 0 4px 12px rgba(0, 0, 0, 0.1);
1357
+ --leafer-point-shadow-zoom: 0 2px 8px rgba(0, 0, 0, 0.15);
1358
+
1359
+ --leafer-point-transition-time: 0.2s;
1360
+ --leafer-point-animation-gradient: 2s;
1361
+ }
1362
+ </style>
1363
+ <style scoped>
1364
+ .point-annotation {
1365
+ width: 100%;
1366
+ height: 100%;
1367
+ }
1368
+
1369
+ .canvas-container {
1370
+ width: 100%;
1371
+ height: calc(100% - 55px);
1372
+ position: relative;
1373
+ overflow: hidden;
1374
+ outline: none;
1375
+ }
1376
+
1377
+ .canvas-container:focus {
1378
+ outline: 2px solid var(--leafer-point-color-primary);
1379
+ outline-offset: -2px;
1380
+ }
1381
+
1382
+ .loading-overlay {
1383
+ position: absolute;
1384
+ top: 50%;
1385
+ left: 50%;
1386
+ transform: translate(-50%, -50%);
1387
+ background-color: var(--leafer-point-color-background-light);
1388
+ border-radius: var(--leafer-point-border-radius-overlay);
1389
+ box-shadow: var(--leafer-point-shadow-overlay);
1390
+ overflow: hidden;
1391
+ display: flex;
1392
+ justify-content: center;
1393
+ align-items: center;
1394
+ z-index: 1000;
1395
+ min-width: 100%;
1396
+ min-height: 100%;
1397
+ }
1398
+
1399
+ .gradient-animation {
1400
+ position: absolute;
1401
+ top: 0;
1402
+ left: 0;
1403
+ right: 0;
1404
+ bottom: 0;
1405
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1406
+ background-size: 200% 200%;
1407
+ animation: gradientShift var(--leafer-point-animation-gradient) ease-in-out
1408
+ infinite;
1409
+ opacity: 0.7;
1410
+ }
1411
+
1412
+ @keyframes gradientShift {
1413
+ 0% {
1414
+ background-position: 0% 50%;
1415
+ }
1416
+ 50% {
1417
+ background-position: 100% 50%;
1418
+ }
1419
+ 100% {
1420
+ background-position: 0% 50%;
1421
+ }
1422
+ }
1423
+
1424
+ .loading-text {
1425
+ position: relative;
1426
+ z-index: 1;
1427
+ color: white;
1428
+ font-size: 16px;
1429
+ font-weight: 500;
1430
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
1431
+ }
1432
+
1433
+ .error-overlay {
1434
+ position: absolute;
1435
+ top: 50%;
1436
+ left: 50%;
1437
+ transform: translate(-50%, -50%);
1438
+ background-color: var(--leafer-point-color-white);
1439
+ border-radius: var(--leafer-point-border-radius-overlay);
1440
+ box-shadow: var(--leafer-point-shadow-overlay);
1441
+ padding: var(--leafer-point-padding-error);
1442
+ display: flex;
1443
+ flex-direction: column;
1444
+ justify-content: center;
1445
+ align-items: center;
1446
+ z-index: 1000;
1447
+ min-width: 200px;
1448
+ }
1449
+
1450
+ .error-overlay p {
1451
+ margin-bottom: 20px;
1452
+ color: var(--leafer-point-color-error);
1453
+ font-size: 16px;
1454
+ }
1455
+
1456
+ .error-overlay button {
1457
+ padding: var(--leafer-point-padding-error-button);
1458
+ background-color: var(--leafer-point-color-button);
1459
+ color: white;
1460
+ border: none;
1461
+ border-radius: var(--leafer-point-border-radius-tool-button);
1462
+ cursor: pointer;
1463
+ font-size: 14px;
1464
+ }
1465
+
1466
+ .error-overlay button:hover {
1467
+ background-color: var(--leafer-point-color-button-hover);
1468
+ }
1469
+
1470
+ .zoom-controller {
1471
+ position: absolute;
1472
+ left: 16px;
1473
+ bottom: 16px;
1474
+ display: flex;
1475
+ align-items: center;
1476
+ background-color: var(--leafer-point-color-white);
1477
+ border-radius: var(--leafer-point-border-radius-zoom);
1478
+ box-shadow: var(--leafer-point-shadow-zoom);
1479
+ overflow: hidden;
1480
+ z-index: 100;
1481
+ }
1482
+
1483
+ .zoom-button {
1484
+ width: var(--leafer-point-size-zoom-button);
1485
+ height: var(--leafer-point-size-zoom-button);
1486
+ border: none;
1487
+ background-color: var(--leafer-point-color-white);
1488
+ color: var(--leafer-point-color-text);
1489
+ cursor: pointer;
1490
+ display: flex;
1491
+ justify-content: center;
1492
+ align-items: center;
1493
+ transition: all var(--leafer-point-transition-time) ease;
1494
+ position: relative;
1495
+ }
1496
+
1497
+ .zoom-button:hover {
1498
+ background-color: var(--leafer-point-color-background-light);
1499
+ color: var(--leafer-point-color-primary);
1500
+ }
1501
+
1502
+ .zoom-button:active {
1503
+ background-color: #e0e0e0;
1504
+ }
1505
+
1506
+ .zoom-value {
1507
+ min-width: var(--leafer-point-size-zoom-value);
1508
+ height: var(--leafer-point-size-zoom-button);
1509
+ line-height: var(--leafer-point-size-zoom-button);
1510
+ text-align: center;
1511
+ font-size: 14px;
1512
+ font-weight: 500;
1513
+ color: var(--leafer-point-color-text);
1514
+ cursor: pointer;
1515
+ border-left: 1px solid var(--leafer-point-color-border-light);
1516
+ border-right: 1px solid var(--leafer-point-color-border-light);
1517
+ transition: all var(--leafer-point-transition-time) ease;
1518
+ position: relative;
1519
+ }
1520
+ .zoom-value .hotkey-hint {
1521
+ line-height: 1;
1522
+ }
1523
+
1524
+ .zoom-value:hover {
1525
+ background-color: var(--leafer-point-color-background-light);
1526
+ color: var(--leafer-point-color-primary);
1527
+ }
1528
+
1529
+ .toolbar {
1530
+ display: flex;
1531
+ justify-content: center;
1532
+ align-items: center;
1533
+ padding: var(--leafer-point-padding-toolbar);
1534
+ background-color: var(--leafer-point-color-background);
1535
+ border-top: 1px solid var(--leafer-point-color-border);
1536
+ gap: 10px;
1537
+ }
1538
+
1539
+ .tool-button {
1540
+ padding: var(--leafer-point-padding-tool-button);
1541
+ border: none;
1542
+ border-radius: var(--leafer-point-border-radius-tool-button);
1543
+ background-color: var(--leafer-point-color-white);
1544
+ color: var(--leafer-point-color-text);
1545
+ cursor: pointer;
1546
+ display: flex;
1547
+ justify-content: center;
1548
+ align-items: center;
1549
+ transition: all var(--leafer-point-transition-time) ease;
1550
+ position: relative;
1551
+ box-shadow: var(--leafer-point-shadow-tool-button);
1552
+ }
1553
+
1554
+ .tool-button:hover {
1555
+ background-color: var(--leafer-point-color-background-light);
1556
+ color: var(--leafer-point-color-primary);
1557
+ box-shadow: var(--leafer-point-shadow-tool-button-hover);
1558
+ }
1559
+
1560
+ .tool-button:active {
1561
+ transform: translateY(1px);
1562
+ box-shadow: var(--leafer-point-shadow-tool-button);
1563
+ }
1564
+
1565
+ .tool-button.active {
1566
+ background-color: var(--leafer-point-color-primary);
1567
+ color: white;
1568
+ box-shadow: var(--leafer-point-shadow-tool-button-active);
1569
+ }
1570
+
1571
+ .hotkey-hint {
1572
+ position: absolute;
1573
+ top: 0;
1574
+ right: 0;
1575
+ font-size: var(--leafer-point-font-size-hotkey);
1576
+ background-color: rgba(0, 0, 0, 0.6);
1577
+ color: white;
1578
+ padding: var(--leafer-point-padding-hotkey);
1579
+ border-radius: var(--leafer-point-border-radius-hotkey);
1580
+ pointer-events: none;
1581
+ white-space: nowrap;
1582
+ }
1583
+
1584
+ .size-control {
1585
+ display: flex;
1586
+ align-items: center;
1587
+ gap: 8px;
1588
+ padding: var(--leafer-point-padding-tool-button);
1589
+ background-color: var(--leafer-point-color-white);
1590
+ border-radius: var(--leafer-point-border-radius-tool-button);
1591
+ box-shadow: var(--leafer-point-shadow-tool-button);
1592
+ }
1593
+
1594
+ .size-label {
1595
+ font-size: 12px;
1596
+ color: var(--leafer-point-color-text);
1597
+ white-space: nowrap;
1598
+ }
1599
+
1600
+ .size-slider {
1601
+ width: 120px;
1602
+ height: 8px;
1603
+ background: #e0e0e0;
1604
+ border-radius: 4px;
1605
+ outline: none;
1606
+ -webkit-appearance: none;
1607
+ appearance: none;
1608
+ cursor: pointer;
1609
+ }
1610
+
1611
+ .size-slider::-webkit-slider-thumb {
1612
+ -webkit-appearance: none;
1613
+ appearance: none;
1614
+ width: 18px;
1615
+ height: 18px;
1616
+ background: var(--leafer-point-color-primary);
1617
+ border-radius: 50%;
1618
+ cursor: pointer;
1619
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
1620
+ transition: all var(--leafer-point-transition-time) ease;
1621
+ border: 2px solid white;
1622
+ }
1623
+
1624
+ .size-slider::-webkit-slider-thumb:hover,
1625
+ .size-slider::-webkit-slider-thumb:active,
1626
+ .size-slider:focus::-webkit-slider-thumb {
1627
+ transform: scale(1.15);
1628
+ background: var(--leafer-point-color-primary-hover);
1629
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
1630
+ }
1631
+
1632
+ .size-slider::-moz-range-thumb {
1633
+ width: 18px;
1634
+ height: 18px;
1635
+ background: var(--leafer-point-color-primary);
1636
+ border-radius: 50%;
1637
+ cursor: pointer;
1638
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
1639
+ border: none;
1640
+ border: 2px solid white;
1641
+ transition: all var(--leafer-point-transition-time) ease;
1642
+ }
1643
+
1644
+ .size-slider::-moz-range-thumb:hover,
1645
+ .size-slider::-moz-range-thumb:active,
1646
+ .size-slider:focus::-moz-range-thumb {
1647
+ transform: scale(1.15);
1648
+ background: var(--leafer-point-color-primary-hover);
1649
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
1650
+ }
1651
+
1652
+ .size-slider:focus {
1653
+ outline: none;
1654
+ }
1655
+
1656
+ .size-value {
1657
+ min-width: 30px;
1658
+ text-align: center;
1659
+ font-size: 12px;
1660
+ color: var(--leafer-point-color-primary);
1661
+ font-weight: 600;
1662
+ }
1663
+ </style>