@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,835 @@
1
+ # LeaferJS 开发指南 - 点标注与笔刷工具项目实战
2
+
3
+ ## 目录
4
+
5
+ 1. [LeaferJS 核心概念](#leaferjs-核心概念)
6
+ 2. [项目架构设计](#项目架构设计)
7
+ 3. [关键技术实现](#关键技术实现)
8
+ 4. [性能优化技巧](#性能优化技巧)
9
+ 5. [常见问题解决方案](#常见问题解决方案)
10
+
11
+ ---
12
+
13
+ ## LeaferJS 核心概念
14
+
15
+ ### 1.1 LeaferUI 基础
16
+
17
+ LeaferUI 是一个高性能的 2D 图形渲染引擎,基于 Canvas 实现。项目使用 LeaferUI 2.0.8+ 版本。
18
+
19
+ #### 核心类
20
+
21
+ ```typescript
22
+ import { App, Group, Ellipse, Text, Image, Canvas, PointerEvent, ZoomEvent } from 'leafer-ui'
23
+ ```
24
+
25
+ | 类名 | 说明 |
26
+ |------|------|
27
+ | App | 应用实例,管理整个画布 |
28
+ | Group | 组容器,用于组织多个元素 |
29
+ | Ellipse | 椭圆/圆形元素 |
30
+ | Text | 文本元素 |
31
+ | Image | 图片元素 |
32
+ | Canvas | Canvas 元素,用于自定义绘制 |
33
+ | PointerEvent | 指针事件 |
34
+ | ZoomEvent | 缩放事件 |
35
+
36
+ #### 常用方法
37
+
38
+ ```typescript
39
+ // 创建应用
40
+ const app = new App({
41
+ view: container, // DOM 容器
42
+ width: 800,
43
+ height: 600,
44
+ fill: '#f5f5f5'
45
+ })
46
+
47
+ // 添加元素
48
+ const group = new Group()
49
+ app.tree.add(group)
50
+
51
+ // 元素操作
52
+ element.set({ x: 100, y: 100 })
53
+ element.destroy()
54
+
55
+ // 视口操作
56
+ app.tree.zoom(2) // 缩放
57
+ app.tree.x = 100 // 平移
58
+ app.tree.y = 100
59
+ ```
60
+
61
+ ### 1.2 插件系统
62
+
63
+ 项目使用多个 Leafer 插件:
64
+
65
+ ```typescript
66
+ import '@leafer-in/editor' // 编辑器插件
67
+ import '@leafer-in/resize' // 缩放插件
68
+ import '@leafer-in/viewport' // 视口插件
69
+ import '@leafer-in/view' // 视图插件
70
+ import '@leafer-in/text-editor' // 文本编辑插件
71
+ ```
72
+
73
+ #### Editor 插件使用
74
+
75
+ ```typescript
76
+ // 启用/禁用编辑器
77
+ app.editor.config.moveable = true
78
+ app.editor.config.resizeable = false
79
+ app.editor.config.multipleSelect = true
80
+
81
+ // 获取选中元素
82
+ const selected = app.editor.list
83
+
84
+ // 选中元素
85
+ app.editor.select(element)
86
+
87
+ // 取消选中
88
+ app.editor.cancel()
89
+ ```
90
+
91
+ ### 1.3 事件系统
92
+
93
+ LeaferUI 提供完整的事件系统:
94
+
95
+ ```typescript
96
+ // 指针事件
97
+ app.on(PointerEvent.DOWN, handleDown)
98
+ app.on(PointerEvent.MOVE, handleMove)
99
+ app.on(PointerEvent.UP, handleUp)
100
+ app.on(PointerEvent.TAP, handleTap)
101
+
102
+ // 缩放事件
103
+ app.on(ZoomEvent.ZOOM, handleZoom)
104
+
105
+ // 编辑器事件
106
+ import { EditorEvent } from '@leafer-in/editor'
107
+ app.editor.on(EditorEvent.SELECT, handleSelect)
108
+ ```
109
+
110
+ #### 坐标转换
111
+
112
+ ```typescript
113
+ // 获取相对于内容层的坐标
114
+ const point = contentLayer.getBoxPoint({ x: event.x, y: event.y })
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 项目架构设计
120
+
121
+ ### 2.1 组件架构
122
+
123
+ ```
124
+ PointAnnotation (主组件)
125
+ ├── Canvas 容器与状态管理
126
+ ├── 工具栏 UI
127
+ ├── 笔刷配置面板
128
+ ├── 缩放控制器
129
+ └── 核心业务逻辑
130
+ ├── 工具切换
131
+ ├── 事件处理
132
+ ├── 命令管理
133
+ ├── 数据导出
134
+ └── 图片加载
135
+ ```
136
+
137
+ ### 2.2 图层设计
138
+
139
+ ```
140
+ app.tree (根层)
141
+ ├── contentLayer (内容层)
142
+ │ ├── imageBox (图片)
143
+ │ └── brushGroup (笔刷组)
144
+ │ └── canvasBrush (Canvas 元素)
145
+ └── pointLayer (点标注层)
146
+ ├── point1 (点标注元素)
147
+ ├── point2
148
+ └── ...
149
+ ```
150
+
151
+ #### 关键设计:使用 Group 控制透明度
152
+
153
+ ```typescript
154
+ // CanvasBrush 类设计
155
+ class CanvasBrush {
156
+ private group: Group
157
+ private canvas: Canvas
158
+ private ctx: CanvasRenderingContext2D
159
+
160
+ constructor(width: number, height: number) {
161
+ this.group = new Group()
162
+ this.canvas = new Canvas({ width, height })
163
+ this.group.add(this.canvas)
164
+
165
+ // 在 Group 上设置透明度,避免 Canvas 上多次叠加
166
+ this.group.opacity = 0.55
167
+ }
168
+ }
169
+ ```
170
+
171
+ ### 2.3 元素封装
172
+
173
+ 自定义元素需要继承 Group 并实现特定功能:
174
+
175
+ ```typescript
176
+ import { Group, Ellipse, Text } from 'leafer-ui'
177
+
178
+ export class PointAnnotationElement extends Group {
179
+ public circle: Ellipse
180
+ public label: Text
181
+ public _element_tag = 'point-annotation'
182
+ public data: PointAnnotation
183
+
184
+ constructor(data: PointAnnotation, style: PointStyle) {
185
+ super()
186
+
187
+ this.data = data
188
+ this.id = data.id
189
+ this.x = data.pixel.x
190
+ this.y = data.pixel.y
191
+
192
+ this.initCircle(style)
193
+ this.initLabel(data.label, style)
194
+ }
195
+ }
196
+ ```
197
+
198
+ ---
199
+
200
+ ## 关键技术实现
201
+
202
+ ### 3.1 笔刷绘制实现
203
+
204
+ #### 核心原理
205
+
206
+ 使用 LeaferJS 的 Canvas 元素,通过原生 Canvas 2D API 绘制:
207
+
208
+ ```typescript
209
+ class CanvasBrush {
210
+ private canvas: Canvas
211
+ private ctx: CanvasRenderingContext2D
212
+ private lastPoint: { x: number; y: number } | null = null
213
+
214
+ constructor(width: number, height: number) {
215
+ this.canvas = new Canvas({ width, height })
216
+ this.ctx = this.canvas.context as CanvasRenderingContext2D
217
+ }
218
+
219
+ // 绘制点
220
+ drawPoint(x: number, y: number, size: number, color: string) {
221
+ this.ctx.fillStyle = color
222
+ this.ctx.beginPath()
223
+ this.ctx.arc(x, y, size / 2, 0, Math.PI * 2)
224
+ this.ctx.fill()
225
+ }
226
+
227
+ // 连线(保证连续性)
228
+ drawLine(x1: number, y1: number, x2: number, y2: number, size: number, color: string) {
229
+ this.ctx.strokeStyle = color
230
+ this.ctx.lineWidth = size
231
+ this.ctx.lineCap = 'round'
232
+ this.ctx.lineJoin = 'round'
233
+ this.ctx.beginPath()
234
+ this.ctx.moveTo(x1, y1)
235
+ this.ctx.lineTo(x2, y2)
236
+ this.ctx.stroke()
237
+ }
238
+
239
+ // 绘制方法
240
+ draw(x: number, y: number, size: number, color: string, continuity: number) {
241
+ if (this.lastPoint) {
242
+ const dx = x - this.lastPoint.x
243
+ const dy = y - this.lastPoint.y
244
+ const distance = Math.sqrt(dx * dx + dy * dy)
245
+
246
+ if (distance > continuity) {
247
+ // 距离超过阈值,连线
248
+ this.drawLine(this.lastPoint.x, this.lastPoint.y, x, y, size, color)
249
+ }
250
+ }
251
+
252
+ this.drawPoint(x, y, size, color)
253
+ this.lastPoint = { x, y }
254
+
255
+ // 触发重绘
256
+ this.canvas.paint()
257
+ }
258
+
259
+ // 擦除方法
260
+ erase(x: number, y: number, size: number, continuity: number) {
261
+ if (this.lastPoint) {
262
+ const dx = x - this.lastPoint.x
263
+ const dy = y - this.lastPoint.y
264
+ const distance = Math.sqrt(dx * dx + dy * dy)
265
+
266
+ if (distance > continuity) {
267
+ this.ctx.globalCompositeOperation = 'destination-out'
268
+ this.drawLine(this.lastPoint.x, this.lastPoint.y, x, y, size, '#000000')
269
+ this.ctx.globalCompositeOperation = 'source-over'
270
+ }
271
+ }
272
+
273
+ this.ctx.globalCompositeOperation = 'destination-out'
274
+ this.drawPoint(x, y, size, '#000000')
275
+ this.ctx.globalCompositeOperation = 'source-over'
276
+ this.lastPoint = { x, y }
277
+ this.canvas.paint()
278
+ }
279
+
280
+ // 检测是否有内容
281
+ hasContent(): boolean {
282
+ if (this.canvas.width === 0 || this.canvas.height === 0) return false
283
+ const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
284
+ const data = imageData.data
285
+ for (let i = 3; i < data.length; i += 4) {
286
+ if (data[i] > 0) return true
287
+ }
288
+ return false
289
+ }
290
+
291
+ // 获取图片数据
292
+ getImageData(): string {
293
+ return this.canvas.toDataURL('image/png')
294
+ }
295
+
296
+ // 恢复图片数据
297
+ restoreImageData(imageData: string): Promise<void> {
298
+ return new Promise((resolve) => {
299
+ const img = document.createElement('img')
300
+ img.onload = () => {
301
+ this.ctx.drawImage(img, 0, 0)
302
+ this.canvas.paint()
303
+ resolve()
304
+ }
305
+ img.src = imageData
306
+ })
307
+ }
308
+
309
+ // 清除
310
+ clear() {
311
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
312
+ this.canvas.paint()
313
+ this.lastPoint = null
314
+ }
315
+
316
+ // 控制事件拦截
317
+ setPointerEvents(value: boolean) {
318
+ this.canvas.pointerEvents = value ? 'all' : 'none'
319
+ }
320
+ }
321
+ ```
322
+
323
+ ### 3.2 撤销/重做系统
324
+
325
+ 使用 `@zzalai/leafer-undo-redo` 插件,实现命令模式:
326
+
327
+ #### 命令接口
328
+
329
+ ```typescript
330
+ interface ICommand {
331
+ execute(): void
332
+ undo(): void
333
+ redo(): void
334
+ }
335
+ ```
336
+
337
+ #### 点标注添加命令
338
+
339
+ ```typescript
340
+ export class AddPointCommand implements ICommand {
341
+ private container: Group
342
+ private element: PointAnnotationElement
343
+ private dataArray: PointAnnotation[]
344
+ private pointData: PointAnnotation
345
+
346
+ constructor(
347
+ container: Group,
348
+ element: PointAnnotationElement,
349
+ dataArray: PointAnnotation[],
350
+ pointData: PointAnnotation
351
+ ) {
352
+ this.container = container
353
+ this.element = element
354
+ this.dataArray = dataArray
355
+ this.pointData = pointData
356
+ }
357
+
358
+ execute(): void {
359
+ this.container.add(this.element)
360
+ this.dataArray.push(this.pointData)
361
+ }
362
+
363
+ undo(): void {
364
+ this.container.remove(this.element)
365
+ const index = this.dataArray.findIndex(p => p.id === this.pointData.id)
366
+ if (index > -1) {
367
+ this.dataArray.splice(index, 1)
368
+ }
369
+ }
370
+
371
+ redo(): void {
372
+ this.execute()
373
+ }
374
+ }
375
+ ```
376
+
377
+ #### 点标注删除命令
378
+
379
+ ```typescript
380
+ export class RemovePointCommand implements ICommand {
381
+ private container: Group
382
+ private element: PointAnnotationElement
383
+ private dataArray: PointAnnotation[]
384
+ private data: PointAnnotation
385
+ private index: number
386
+
387
+ constructor(container: Group, element: PointAnnotationElement, dataArray: PointAnnotation[]) {
388
+ this.container = container
389
+ this.element = element
390
+ this.dataArray = dataArray
391
+ this.data = element.data
392
+ this.index = dataArray.findIndex(p => p.id === element.id)
393
+ }
394
+
395
+ execute(): void {
396
+ this.container.remove(this.element)
397
+ if (this.index > -1) {
398
+ this.dataArray.splice(this.index, 1)
399
+ }
400
+ }
401
+
402
+ undo(): void {
403
+ this.container.add(this.element)
404
+ if (this.index > -1) {
405
+ this.dataArray.splice(this.index, 0, this.data)
406
+ } else {
407
+ this.dataArray.push(this.data)
408
+ }
409
+ }
410
+
411
+ redo(): void {
412
+ this.execute()
413
+ }
414
+ }
415
+ ```
416
+
417
+ #### 笔刷快照命令
418
+
419
+ ```typescript
420
+ export class BrushSnapshotCommand implements ICommand {
421
+ private brush: CanvasBrush
422
+ private beforeImage: string
423
+ private afterImage: string | null = null
424
+ private isClear: boolean
425
+
426
+ constructor(brush: CanvasBrush, beforeImage: string, isClear: boolean = false) {
427
+ this.brush = brush
428
+ this.beforeImage = beforeImage
429
+ this.isClear = isClear
430
+ }
431
+
432
+ execute(): void {
433
+ if (this.isClear) {
434
+ // 用于清除操作,execute 保存当前状态,undo 恢复
435
+ this.afterImage = this.brush.getImageData()
436
+ this.brush.clear()
437
+ } else {
438
+ // 普通操作,execute 已在绘制时完成
439
+ this.afterImage = this.brush.getImageData()
440
+ }
441
+ }
442
+
443
+ undo(): void {
444
+ this.brush.restoreImageData(this.beforeImage)
445
+ }
446
+
447
+ redo(): void {
448
+ if (this.afterImage) {
449
+ this.brush.restoreImageData(this.afterImage)
450
+ }
451
+ }
452
+ }
453
+ ```
454
+
455
+ #### 命令管理器使用
456
+
457
+ ```typescript
458
+ import { CommandManager } from '@zzalai/leafer-undo-redo'
459
+
460
+ // 初始化
461
+ const commandManager = new CommandManager(100)
462
+
463
+ // 执行命令
464
+ commandManager.executeCommand(new AddPointCommand(container, element, dataArray, data))
465
+
466
+ // 撤销
467
+ if (commandManager.canUndo()) {
468
+ commandManager.undo()
469
+ }
470
+
471
+ // 重做
472
+ if (commandManager.canRedo()) {
473
+ commandManager.redo()
474
+ }
475
+ ```
476
+
477
+ ### 3.3 导出功能实现
478
+
479
+ #### JSON 导出
480
+
481
+ ```typescript
482
+ exportCanvasJSON(): string {
483
+ const exportData = {
484
+ version: '1.0',
485
+ imageUrl: props.imageSource.url || '',
486
+ imageWidth: imageWidth.value,
487
+ imageHeight: imageHeight.value,
488
+ pointAnnotations: [...pointAnnotations.value],
489
+ brushMask: canvasBrush?.getImageData() || null,
490
+ exportTime: Date.now()
491
+ }
492
+ return JSON.stringify(exportData, null, 2)
493
+ }
494
+ ```
495
+
496
+ #### COCO 格式导出
497
+
498
+ ```typescript
499
+ export function exportCOCOFormat(
500
+ annotations: PointAnnotation[],
501
+ imageUrl: string,
502
+ imageWidth: number,
503
+ imageHeight: number
504
+ ): COCOExport {
505
+ const cocoData: COCOExport = {
506
+ info: {
507
+ description: 'Point Annotation Export',
508
+ version: '1.0',
509
+ year: new Date().getFullYear(),
510
+ date_created: new Date().toISOString().split('T')[0]
511
+ },
512
+ licenses: [],
513
+ images: [
514
+ {
515
+ id: 1,
516
+ file_name: imageUrl.split('/').pop() || 'image.jpg',
517
+ width: imageWidth,
518
+ height: imageHeight
519
+ }
520
+ ],
521
+ annotations: annotations.map((anno, index) => {
522
+ const radius = 12
523
+ return {
524
+ id: index + 1,
525
+ image_id: 1,
526
+ category_id: 1,
527
+ keypoints: [anno.pixel.x, anno.pixel.y, 2],
528
+ num_keypoints: 1,
529
+ bbox: [anno.pixel.x - radius, anno.pixel.y - radius, radius * 2, radius * 2],
530
+ area: Math.PI * radius * radius,
531
+ iscrowd: 0
532
+ }
533
+ }),
534
+ categories: [
535
+ {
536
+ id: 1,
537
+ name: 'point',
538
+ keypoints: ['point'],
539
+ skeleton: []
540
+ }
541
+ ]
542
+ }
543
+ return cocoData
544
+ }
545
+ ```
546
+
547
+ #### YOLO 格式导出
548
+
549
+ ```typescript
550
+ export function exportYOLOFormat(
551
+ annotations: PointAnnotation[],
552
+ imageWidth: number,
553
+ imageHeight: number
554
+ ): YOLOExport {
555
+ const lines = annotations.map((anno) => {
556
+ const x = anno.normalized.x
557
+ const y = anno.normalized.y
558
+ const w = 24 / imageWidth
559
+ const h = 24 / imageHeight
560
+ return `0 ${x} ${y} ${w} ${h}`
561
+ })
562
+ return {
563
+ annotations: lines.join('\n'),
564
+ classNames: 'point'
565
+ }
566
+ }
567
+ ```
568
+
569
+ #### 二值图导出
570
+
571
+ ```typescript
572
+ async function exportMaskImage(
573
+ format: 'png' | 'jpg' | 'jpeg' = 'png',
574
+ fgColor: 'black' | 'white' = 'black'
575
+ ): Promise<string | null> {
576
+ const maskData = canvasBrush?.getImageData()
577
+ if (!maskData) return null
578
+
579
+ return new Promise((resolve) => {
580
+ const img = document.createElement('img')
581
+ img.onload = () => {
582
+ const canvas = document.createElement('canvas')
583
+ canvas.width = imageWidth.value || 0
584
+ canvas.height = imageHeight.value || 0
585
+ const ctx = canvas.getContext('2d')
586
+ if (!ctx) {
587
+ resolve(null)
588
+ return
589
+ }
590
+
591
+ ctx.drawImage(img, 0, 0)
592
+
593
+ const isWhite = fgColor === 'white'
594
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
595
+ const data = imageData.data
596
+
597
+ for (let i = 0; i < data.length; i += 4) {
598
+ if (data[i + 3] > 0) {
599
+ data[i] = isWhite ? 255 : 0
600
+ data[i + 1] = isWhite ? 255 : 0
601
+ data[i + 2] = isWhite ? 255 : 0
602
+ data[i + 3] = 255
603
+ } else if (format !== 'png') {
604
+ data[i] = isWhite ? 0 : 255
605
+ data[i + 1] = isWhite ? 0 : 255
606
+ data[i + 2] = isWhite ? 0 : 255
607
+ data[i + 3] = 255
608
+ }
609
+ }
610
+ ctx.putImageData(imageData, 0, 0)
611
+
612
+ if (format === 'png') {
613
+ resolve(canvas.toDataURL('image/png'))
614
+ } else {
615
+ resolve(canvas.toDataURL('image/jpeg', 0.95))
616
+ }
617
+ }
618
+
619
+ img.onerror = () => {
620
+ resolve(null)
621
+ }
622
+
623
+ img.src = maskData
624
+ })
625
+ }
626
+ ```
627
+
628
+ ### 3.4 热键系统
629
+
630
+ 使用 `tinykeys` 实现热键:
631
+
632
+ ```typescript
633
+ import { tinykeys } from 'tinykeys'
634
+
635
+ const unsubscribe = tinykeys(window, {
636
+ V: (e) => {
637
+ if (!isCanvasFocused && !isMouseOverCanvas) return
638
+ e.preventDefault()
639
+ selectTool()
640
+ },
641
+ P: (e) => {
642
+ if (!isCanvasFocused && !isMouseOverCanvas) return
643
+ e.preventDefault()
644
+ pointTool()
645
+ },
646
+ B: (e) => {
647
+ if (!isCanvasFocused && !isMouseOverCanvas) return
648
+ e.preventDefault()
649
+ brushTool()
650
+ },
651
+ E: (e) => {
652
+ if (!isCanvasFocused && !isMouseOverCanvas) return
653
+ e.preventDefault()
654
+ eraserTool()
655
+ },
656
+ '$mod+KeyZ': (e) => {
657
+ if (!isCanvasFocused && !isMouseOverCanvas) return
658
+ e.preventDefault()
659
+ e.stopPropagation()
660
+ undo()
661
+ },
662
+ '$mod+KeyY': (e) => {
663
+ if (!isCanvasFocused && !isMouseOverCanvas) return
664
+ e.preventDefault()
665
+ e.stopPropagation()
666
+ redo()
667
+ },
668
+ Delete: (e) => {
669
+ if (!isCanvasFocused && !isMouseOverCanvas) return
670
+ e.preventDefault()
671
+ e.stopPropagation()
672
+ deleteSelected()
673
+ }
674
+ })
675
+
676
+ onUnmounted(() => {
677
+ unsubscribe()
678
+ })
679
+ ```
680
+
681
+ ---
682
+
683
+ ## 性能优化技巧
684
+
685
+ ### 4.1 渲染优化
686
+
687
+ 1. **使用 Group 组织元素**
688
+ - 减少重绘区域
689
+ - 批量处理子元素
690
+
691
+ 2. **合理使用 zIndex**
692
+ - 减少不必要的层级处理
693
+ - 固定层顺序
694
+
695
+ 3. **使用 set() 批量更新**
696
+ ```typescript
697
+ element.set({ x: 100, y: 100, width: 200 })
698
+ ```
699
+
700
+ ### 4.2 事件处理优化
701
+
702
+ 1. **条件监听**
703
+ - 仅在需要时监听事件
704
+ - CanvasBrush 使用 pointerEvents 控制
705
+
706
+ 2. **避免高频操作**
707
+ - 笔刷绘制使用 requestAnimationFrame 优化
708
+ - 避免在事件处理中执行复杂计算
709
+
710
+ ### 4.3 内存管理
711
+
712
+ 1. **及时销毁元素**
713
+ ```typescript
714
+ element.destroy()
715
+ ```
716
+
717
+ 2. **限制历史记录**
718
+ ```typescript
719
+ const commandManager = new CommandManager(100)
720
+ ```
721
+
722
+ 3. **重用 Canvas**
723
+ - 避免频繁创建新 Canvas 元素
724
+
725
+ ---
726
+
727
+ ## 常见问题解决方案
728
+
729
+ ### 5.1 笔刷叠加透明度问题
730
+
731
+ **问题**:笔刷涂抹同一位置多次,透明度叠加导致颜色变深。
732
+
733
+ **解决方案**:使用 Group 控制整体透明度,而不是在 Canvas 上设置透明度。
734
+
735
+ ```typescript
736
+ const group = new Group()
737
+ const canvas = new Canvas()
738
+ group.add(canvas)
739
+ group.opacity = 0.55 // 在 Group 上设置透明度
740
+ ```
741
+
742
+ ### 5.2 标签编辑权限控制
743
+
744
+ **问题**:任何工具都能编辑标签,容易误操作。
745
+
746
+ **解决方案**:根据当前工具动态控制标签可编辑性。
747
+
748
+ ```typescript
749
+ function updateLabelEditable(editable: boolean) {
750
+ if (pointLayer && pointLayer.children) {
751
+ pointLayer.children.forEach((element: any) => {
752
+ if (element._element_tag === 'point-annotation' && element.label) {
753
+ element.label.editable = editable
754
+ }
755
+ })
756
+ }
757
+ }
758
+ ```
759
+
760
+ ### 5.3 Editor 状态冲突
761
+
762
+ **问题**:笔刷模式下 Editor 仍在工作,导致冲突。
763
+
764
+ **解决方案**:动态配置 Editor 状态。
765
+
766
+ ```typescript
767
+ function brushTool() {
768
+ currentTool.value = 'brush'
769
+ app.editor.config.moveable = false
770
+ app.editor.config.resizeable = false
771
+ app.editor.config.multipleSelect = false
772
+ canvasBrush.setPointerEvents(true)
773
+ updateLabelEditable(false)
774
+ }
775
+ ```
776
+
777
+ ### 5.4 坐标转换问题
778
+
779
+ **问题**:不同层的坐标需要正确转换。
780
+
781
+ **解决方案**:使用 `getBoxPoint()` 方法。
782
+
783
+ ```typescript
784
+ const point = contentLayer.getBoxPoint({ x: event.x, y: event.y })
785
+ ```
786
+
787
+ ### 5.5 导出时图片加载问题
788
+
789
+ **问题**:异步图片加载导致导出不完整。
790
+
791
+ **解决方案**:使用 Promise 等待图片加载完成。
792
+
793
+ ```typescript
794
+ restoreImageData(imageData: string): Promise<void> {
795
+ return new Promise((resolve) => {
796
+ const img = document.createElement('img')
797
+ img.onload = () => {
798
+ this.ctx.drawImage(img, 0, 0)
799
+ this.canvas.paint()
800
+ resolve()
801
+ }
802
+ img.src = imageData
803
+ })
804
+ }
805
+ ```
806
+
807
+ ### 5.6 点标注大小不随缩放变化
808
+
809
+ **问题**:缩放画布时,点标注也跟着缩放,影响视觉效果。
810
+
811
+ **解决方案**:监听缩放事件,动态调整点标注大小。
812
+
813
+ ```typescript
814
+ const changePointScaleRelativeCanvas = (pointLayer: Group) => {
815
+ if (!pointStyle.fixedSizeOnZoom) return
816
+
817
+ if (pointLayer && pointLayer.children) {
818
+ const scale = 1 / (app?.tree.scaleX || 1)
819
+ const finalScale = scale * (pointStyle.fixedSizeScale || 1)
820
+ pointLayer.children.forEach((element) => {
821
+ element.scale = finalScale
822
+ })
823
+ }
824
+ }
825
+
826
+ app.on(ZoomEvent.ZOOM, () => {
827
+ updateZoomLevel()
828
+ changePointScaleRelativeCanvas(pointLayer)
829
+ })
830
+ ```
831
+
832
+ ---
833
+
834
+ **文档版本**:1.0
835
+ **最后更新**:2024-05-01