@pooder/kit 3.3.0 → 3.5.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.
package/src/feature.ts ADDED
@@ -0,0 +1,724 @@
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ } from "@pooder/core";
9
+ import { Circle, Group, Point, Rect } from "fabric";
10
+ import CanvasService from "./CanvasService";
11
+ import { DielineGeometry } from "./dieline";
12
+ import {
13
+ getNearestPointOnDieline,
14
+ EdgeFeature,
15
+ resolveFeaturePosition,
16
+ } from "./geometry";
17
+ import { Coordinate } from "./coordinate";
18
+
19
+ export class FeatureTool implements Extension {
20
+ id = "pooder.kit.feature";
21
+
22
+ public metadata = {
23
+ name: "FeatureTool",
24
+ };
25
+
26
+ private features: EdgeFeature[] = [];
27
+ private canvasService?: CanvasService;
28
+ private context?: ExtensionContext;
29
+ private isUpdatingConfig = false;
30
+
31
+ private handleMoving: ((e: any) => void) | null = null;
32
+ private handleModified: ((e: any) => void) | null = null;
33
+ private handleDielineChange: ((geometry: DielineGeometry) => void) | null =
34
+ null;
35
+
36
+ private currentGeometry: DielineGeometry | null = null;
37
+
38
+ constructor(
39
+ options?: Partial<{
40
+ features: EdgeFeature[];
41
+ }>,
42
+ ) {
43
+ if (options) {
44
+ Object.assign(this, options);
45
+ }
46
+ }
47
+
48
+ activate(context: ExtensionContext) {
49
+ this.context = context;
50
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
51
+
52
+ if (!this.canvasService) {
53
+ console.warn("CanvasService not found for FeatureTool");
54
+ return;
55
+ }
56
+
57
+ const configService = context.services.get<ConfigurationService>(
58
+ "ConfigurationService",
59
+ );
60
+ if (configService) {
61
+ this.features = configService.get("dieline.features", []);
62
+
63
+ configService.onAnyChange((e: { key: string; value: any }) => {
64
+ if (this.isUpdatingConfig) return;
65
+
66
+ if (e.key === "dieline.features") {
67
+ this.features = e.value || [];
68
+ this.redraw();
69
+ }
70
+ });
71
+ }
72
+
73
+ this.setup();
74
+ }
75
+
76
+ deactivate(context: ExtensionContext) {
77
+ this.teardown();
78
+ this.canvasService = undefined;
79
+ this.context = undefined;
80
+ }
81
+
82
+ contribute() {
83
+ return {
84
+ [ContributionPointIds.COMMANDS]: [
85
+ {
86
+ command: "addFeature",
87
+ title: "Add Edge Feature",
88
+ handler: (type: "add" | "subtract" = "subtract") => {
89
+ return this.addFeature(type);
90
+ },
91
+ },
92
+ {
93
+ command: "addHole",
94
+ title: "Add Hole",
95
+ handler: () => {
96
+ return this.addFeature("subtract");
97
+ },
98
+ },
99
+ {
100
+ command: "addDoubleLayerHole",
101
+ title: "Add Double Layer Hole",
102
+ handler: () => {
103
+ return this.addDoubleLayerHole();
104
+ },
105
+ },
106
+ {
107
+ command: "clearFeatures",
108
+ title: "Clear Features",
109
+ handler: () => {
110
+ const configService =
111
+ this.context?.services.get<ConfigurationService>(
112
+ "ConfigurationService",
113
+ );
114
+ if (configService) {
115
+ configService.update("dieline.features", []);
116
+ }
117
+ return true;
118
+ },
119
+ },
120
+ ] as CommandContribution[],
121
+ };
122
+ }
123
+
124
+ private addFeature(type: "add" | "subtract") {
125
+ if (!this.canvasService) return false;
126
+
127
+ const configService = this.context?.services.get<ConfigurationService>(
128
+ "ConfigurationService",
129
+ );
130
+ const unit = configService?.get("dieline.unit", "mm") || "mm";
131
+ const defaultSize = Coordinate.convertUnit(10, "mm", unit);
132
+
133
+ // Default to top edge center
134
+ const newFeature: EdgeFeature = {
135
+ id: Date.now().toString(),
136
+ operation: type,
137
+ target: "original",
138
+ shape: "rect",
139
+ x: 0.5,
140
+ y: 0, // Top edge
141
+ width: defaultSize,
142
+ height: defaultSize,
143
+ rotation: 0,
144
+ };
145
+
146
+ if (configService) {
147
+ const current = configService.get(
148
+ "dieline.features",
149
+ [],
150
+ ) as EdgeFeature[];
151
+ configService.update("dieline.features", [...current, newFeature]);
152
+ }
153
+ return true;
154
+ }
155
+
156
+ private addDoubleLayerHole() {
157
+ if (!this.canvasService) return false;
158
+
159
+ const configService = this.context?.services.get<ConfigurationService>(
160
+ "ConfigurationService",
161
+ );
162
+ const unit = configService?.get("dieline.unit", "mm") || "mm";
163
+ const lugRadius = Coordinate.convertUnit(20, "mm", unit);
164
+ const holeRadius = Coordinate.convertUnit(15, "mm", unit);
165
+
166
+ const groupId = Date.now().toString();
167
+ const timestamp = Date.now();
168
+
169
+ // 1. Lug (Outer) - Add
170
+ const lug: EdgeFeature = {
171
+ id: `${timestamp}-lug`,
172
+ groupId,
173
+ operation: "add",
174
+ shape: "circle",
175
+ x: 0.5,
176
+ y: 0,
177
+ radius: lugRadius, // 20mm
178
+ rotation: 0,
179
+ };
180
+
181
+ // 2. Hole (Inner) - Subtract
182
+ const hole: EdgeFeature = {
183
+ id: `${timestamp}-hole`,
184
+ groupId,
185
+ operation: "subtract",
186
+ shape: "circle",
187
+ x: 0.5,
188
+ y: 0,
189
+ radius: holeRadius, // 15mm
190
+ rotation: 0,
191
+ };
192
+
193
+ if (configService) {
194
+ const current = configService.get(
195
+ "dieline.features",
196
+ [],
197
+ ) as EdgeFeature[];
198
+ configService.update("dieline.features", [...current, lug, hole]);
199
+ }
200
+ return true;
201
+ }
202
+
203
+ private getGeometryForFeature(
204
+ geometry: DielineGeometry,
205
+ feature?: EdgeFeature,
206
+ ): DielineGeometry {
207
+ if (feature?.target === "offset" && geometry.offset !== 0) {
208
+ return {
209
+ ...geometry,
210
+ width: geometry.width + geometry.offset * 2,
211
+ height: geometry.height + geometry.offset * 2,
212
+ radius:
213
+ geometry.radius === 0
214
+ ? 0
215
+ : Math.max(0, geometry.radius + geometry.offset),
216
+ };
217
+ }
218
+ return geometry;
219
+ }
220
+
221
+ private setup() {
222
+ if (!this.canvasService || !this.context) return;
223
+ const canvas = this.canvasService.canvas;
224
+
225
+ // 1. Listen for Dieline Geometry Changes
226
+ if (!this.handleDielineChange) {
227
+ this.handleDielineChange = (geometry: DielineGeometry) => {
228
+ this.currentGeometry = geometry;
229
+ this.redraw();
230
+ this.enforceConstraints();
231
+ };
232
+ this.context.eventBus.on(
233
+ "dieline:geometry:change",
234
+ this.handleDielineChange,
235
+ );
236
+ }
237
+
238
+ // 2. Initial Fetch of Geometry
239
+ const commandService = this.context.services.get<any>("CommandService");
240
+ if (commandService) {
241
+ try {
242
+ Promise.resolve(commandService.executeCommand("getGeometry")).then(
243
+ (g) => {
244
+ if (g) {
245
+ this.currentGeometry = g as DielineGeometry;
246
+ this.redraw();
247
+ }
248
+ },
249
+ );
250
+ } catch (e) {}
251
+ }
252
+
253
+ // 3. Setup Canvas Interaction
254
+ if (!this.handleMoving) {
255
+ this.handleMoving = (e: any) => {
256
+ const target = e.target;
257
+ if (!target || target.data?.type !== "feature-marker") return;
258
+ if (!this.currentGeometry) return;
259
+
260
+ // Determine feature to use for snapping context
261
+ let feature: EdgeFeature | undefined;
262
+ if (target.data?.isGroup) {
263
+ const indices = target.data?.indices as number[];
264
+ if (indices && indices.length > 0) {
265
+ feature = this.features[indices[0]];
266
+ }
267
+ } else {
268
+ const index = target.data?.index;
269
+ if (index !== undefined) {
270
+ feature = this.features[index];
271
+ }
272
+ }
273
+
274
+ const geometry = this.getGeometryForFeature(
275
+ this.currentGeometry,
276
+ feature,
277
+ );
278
+
279
+ // Snap to edge during move
280
+ // For Group, target.left/top is group center (or top-left depending on origin)
281
+ // We snap the target position itself.
282
+ const p = new Point(target.left, target.top);
283
+
284
+ // Calculate limit based on target size (min dimension / 2 ensures overlap)
285
+ // Also subtract stroke width to ensure visual overlap (not just tangent)
286
+ // target.strokeWidth for group is usually 0, need a safe default (e.g. 2 for markers)
287
+ const markerStrokeWidth = (target.strokeWidth || 2) * (target.scaleX || 1);
288
+ const minDim = Math.min(target.getScaledWidth(), target.getScaledHeight());
289
+ const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
290
+
291
+ const snapped = this.constrainPosition(p, geometry, limit);
292
+
293
+ target.set({
294
+ left: snapped.x,
295
+ top: snapped.y,
296
+ });
297
+ };
298
+ canvas.on("object:moving", this.handleMoving);
299
+ }
300
+
301
+ if (!this.handleModified) {
302
+ this.handleModified = (e: any) => {
303
+ const target = e.target;
304
+ if (!target || target.data?.type !== "feature-marker") return;
305
+
306
+ // Sync changes back to config
307
+ if (target.data?.isGroup) {
308
+ // It's a Group object
309
+ const groupObj = target as Group;
310
+ // @ts-ignore
311
+ const indices = groupObj.data?.indices as number[];
312
+ if (!indices) return;
313
+
314
+ // We need to update all features in the group based on their new absolute positions.
315
+ // Fabric Group children positions are relative to group center.
316
+ // We need to calculate absolute position for each child.
317
+ // Note: groupObj has already been moved to new position (target.left, target.top)
318
+
319
+ const groupCenter = new Point(groupObj.left, groupObj.top);
320
+ // Get group matrix to transform children
321
+ // Simplified: just add relative coordinates if no rotation/scaling on group
322
+ // We locked rotation/scaling, so it's safe.
323
+
324
+ const newFeatures = [...this.features];
325
+ const { x, y } = this.currentGeometry!; // Center is same
326
+
327
+ // Fabric Group objects have .getObjects() which returns children
328
+ // But children inside group have coordinates relative to group center.
329
+ // center is (0,0) inside the group local coordinate system.
330
+
331
+ groupObj.getObjects().forEach((child, i) => {
332
+ const originalIndex = indices[i];
333
+ const feature = this.features[originalIndex];
334
+ const geometry = this.getGeometryForFeature(
335
+ this.currentGeometry!,
336
+ feature,
337
+ );
338
+ const { width, height } = geometry;
339
+ const layoutLeft = x - width / 2;
340
+ const layoutTop = y - height / 2;
341
+
342
+ // Calculate absolute position
343
+ // child.left/top are relative to group center
344
+ const absX = groupCenter.x + (child.left || 0);
345
+ const absY = groupCenter.y + (child.top || 0);
346
+
347
+ // Normalize
348
+ const normalizedX = width > 0 ? (absX - layoutLeft) / width : 0.5;
349
+ const normalizedY = height > 0 ? (absY - layoutTop) / height : 0.5;
350
+
351
+ newFeatures[originalIndex] = {
352
+ ...newFeatures[originalIndex],
353
+ x: normalizedX,
354
+ y: normalizedY,
355
+ };
356
+ });
357
+
358
+ this.features = newFeatures;
359
+
360
+ const configService =
361
+ this.context?.services.get<ConfigurationService>(
362
+ "ConfigurationService",
363
+ );
364
+ if (configService) {
365
+ this.isUpdatingConfig = true;
366
+ try {
367
+ configService.update("dieline.features", this.features);
368
+ } finally {
369
+ this.isUpdatingConfig = false;
370
+ }
371
+ }
372
+ } else {
373
+ // Single object
374
+ this.syncFeatureFromCanvas(target);
375
+ }
376
+ };
377
+ canvas.on("object:modified", this.handleModified);
378
+ }
379
+ }
380
+
381
+ private teardown() {
382
+ if (!this.canvasService) return;
383
+ const canvas = this.canvasService.canvas;
384
+
385
+ if (this.handleMoving) {
386
+ canvas.off("object:moving", this.handleMoving);
387
+ this.handleMoving = null;
388
+ }
389
+ if (this.handleModified) {
390
+ canvas.off("object:modified", this.handleModified);
391
+ this.handleModified = null;
392
+ }
393
+ if (this.handleDielineChange && this.context) {
394
+ this.context.eventBus.off(
395
+ "dieline:geometry:change",
396
+ this.handleDielineChange,
397
+ );
398
+ this.handleDielineChange = null;
399
+ }
400
+
401
+ const objects = canvas
402
+ .getObjects()
403
+ .filter((obj: any) => obj.data?.type === "feature-marker");
404
+ objects.forEach((obj) => canvas.remove(obj));
405
+
406
+ this.canvasService.requestRenderAll();
407
+ }
408
+
409
+ private constrainPosition(
410
+ p: Point,
411
+ geometry: DielineGeometry,
412
+ limit: number
413
+ ): { x: number; y: number } {
414
+ // Use geometry helper to find nearest point on Base Shape
415
+ // geometry object matches GeometryOptions structure required by getNearestPointOnDieline
416
+ // except for 'features' which we don't need for base shape snapping
417
+ const nearest = getNearestPointOnDieline({ x: p.x, y: p.y }, {
418
+ ...geometry,
419
+ features: [],
420
+ } as any);
421
+
422
+ // Calculate vector from nearest point to current point
423
+ const dx = p.x - nearest.x;
424
+ const dy = p.y - nearest.y;
425
+ const dist = Math.sqrt(dx * dx + dy * dy);
426
+
427
+ // If within limit, allow current position (offset from edge)
428
+ if (dist <= limit) {
429
+ return { x: p.x, y: p.y };
430
+ }
431
+
432
+ // Otherwise, clamp to limit
433
+ const scale = limit / dist;
434
+ return {
435
+ x: nearest.x + dx * scale,
436
+ y: nearest.y + dy * scale,
437
+ };
438
+ }
439
+
440
+ private syncFeatureFromCanvas(target: any) {
441
+ if (!this.currentGeometry || !this.context) return;
442
+
443
+ const index = target.data?.index;
444
+ if (index === undefined || index < 0 || index >= this.features.length)
445
+ return;
446
+
447
+ const feature = this.features[index];
448
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
449
+ const { width, height, x, y } = geometry;
450
+
451
+ // Calculate Normalized Position
452
+ // The geometry x/y is the CENTER.
453
+ const left = x - width / 2;
454
+ const top = y - height / 2;
455
+
456
+ const normalizedX = width > 0 ? (target.left - left) / width : 0.5;
457
+ const normalizedY = height > 0 ? (target.top - top) / height : 0.5;
458
+
459
+ // Update feature
460
+ const updatedFeature = {
461
+ ...feature,
462
+ x: normalizedX,
463
+ y: normalizedY,
464
+ // Could also update rotation if we allowed rotating markers
465
+ };
466
+
467
+ const newFeatures = [...this.features];
468
+ newFeatures[index] = updatedFeature;
469
+ this.features = newFeatures;
470
+
471
+ // Save to config
472
+ const configService = this.context.services.get<ConfigurationService>(
473
+ "ConfigurationService",
474
+ );
475
+ if (configService) {
476
+ this.isUpdatingConfig = true;
477
+ try {
478
+ configService.update("dieline.features", this.features);
479
+ } finally {
480
+ this.isUpdatingConfig = false;
481
+ }
482
+ }
483
+ }
484
+
485
+ private redraw() {
486
+ if (!this.canvasService || !this.currentGeometry) return;
487
+ const canvas = this.canvasService.canvas;
488
+ const geometry = this.currentGeometry;
489
+
490
+ // Remove existing markers
491
+ const existing = canvas
492
+ .getObjects()
493
+ .filter((obj: any) => obj.data?.type === "feature-marker");
494
+ existing.forEach((obj) => canvas.remove(obj));
495
+
496
+ if (!this.features || this.features.length === 0) {
497
+ this.canvasService.requestRenderAll();
498
+ return;
499
+ }
500
+
501
+ const scale = geometry.scale || 1;
502
+ const finalScale = scale;
503
+
504
+ // Group features by groupId
505
+ const groups: { [key: string]: { feature: EdgeFeature; index: number }[] } =
506
+ {};
507
+ const singles: { feature: EdgeFeature; index: number }[] = [];
508
+
509
+ this.features.forEach((f, i) => {
510
+ if (f.groupId) {
511
+ if (!groups[f.groupId]) groups[f.groupId] = [];
512
+ groups[f.groupId].push({ feature: f, index: i });
513
+ } else {
514
+ singles.push({ feature: f, index: i });
515
+ }
516
+ });
517
+
518
+ // Helper to create marker shape
519
+ const createMarkerShape = (
520
+ feature: EdgeFeature,
521
+ pos: { x: number; y: number },
522
+ ) => {
523
+ // Features are in the same unit as geometry.unit
524
+ const featureScale = scale;
525
+
526
+ const visualWidth = (feature.width || 10) * featureScale;
527
+ const visualHeight = (feature.height || 10) * featureScale;
528
+ const visualRadius = (feature.radius || 0) * featureScale;
529
+ const color =
530
+ feature.color ||
531
+ (feature.operation === "add" ? "#00FF00" : "#FF0000");
532
+ const strokeDash =
533
+ feature.strokeDash ||
534
+ (feature.operation === "subtract" ? [4, 4] : undefined);
535
+
536
+ let shape: any;
537
+ if (feature.shape === "rect") {
538
+ shape = new Rect({
539
+ width: visualWidth,
540
+ height: visualHeight,
541
+ rx: visualRadius,
542
+ ry: visualRadius,
543
+ fill: "transparent",
544
+ stroke: color,
545
+ strokeWidth: 2,
546
+ strokeDashArray: strokeDash,
547
+ originX: "center",
548
+ originY: "center",
549
+ left: pos.x,
550
+ top: pos.y,
551
+ });
552
+ } else {
553
+ shape = new Circle({
554
+ radius: visualRadius || 5 * finalScale,
555
+ fill: "transparent",
556
+ stroke: color,
557
+ strokeWidth: 2,
558
+ strokeDashArray: strokeDash,
559
+ originX: "center",
560
+ originY: "center",
561
+ left: pos.x,
562
+ top: pos.y,
563
+ });
564
+ }
565
+ if (feature.rotation) {
566
+ shape.rotate(feature.rotation);
567
+ }
568
+ return shape;
569
+ };
570
+
571
+ // Render Singles
572
+ singles.forEach(({ feature, index }) => {
573
+ const geometry = this.getGeometryForFeature(
574
+ this.currentGeometry!,
575
+ feature,
576
+ );
577
+ const pos = resolveFeaturePosition(feature, geometry);
578
+ const marker = createMarkerShape(feature, pos);
579
+
580
+ marker.set({
581
+ selectable: true,
582
+ hasControls: false,
583
+ hasBorders: false,
584
+ hoverCursor: "move",
585
+ lockRotation: true,
586
+ lockScalingX: true,
587
+ lockScalingY: true,
588
+ data: { type: "feature-marker", index, isGroup: false },
589
+ });
590
+
591
+ // Auto-hide logic
592
+ marker.set("opacity", 0);
593
+ marker.on("mouseover", () => {
594
+ marker.set("opacity", 1);
595
+ canvas.requestRenderAll();
596
+ });
597
+ marker.on("mouseout", () => {
598
+ if (canvas.getActiveObject() !== marker) {
599
+ marker.set("opacity", 0);
600
+ canvas.requestRenderAll();
601
+ }
602
+ });
603
+ marker.on("selected", () => {
604
+ marker.set("opacity", 1);
605
+ canvas.requestRenderAll();
606
+ });
607
+ marker.on("deselected", () => {
608
+ marker.set("opacity", 0);
609
+ canvas.requestRenderAll();
610
+ });
611
+
612
+ canvas.add(marker);
613
+ canvas.bringObjectToFront(marker);
614
+ });
615
+
616
+ // Render Groups
617
+ Object.keys(groups).forEach((groupId) => {
618
+ const members = groups[groupId];
619
+ if (members.length === 0) return;
620
+
621
+ // Calculate group center (average position) to position the group correctly
622
+ // But Fabric Group uses relative coordinates.
623
+ // Easiest way: Create shapes at absolute positions, then Group them.
624
+ // Fabric will auto-calculate group center and adjust children.
625
+
626
+ const shapes = members.map(({ feature }) => {
627
+ const geometry = this.getGeometryForFeature(
628
+ this.currentGeometry!,
629
+ feature,
630
+ );
631
+ const pos = resolveFeaturePosition(feature, geometry);
632
+ return createMarkerShape(feature, pos);
633
+ });
634
+
635
+ const groupObj = new Group(shapes, {
636
+ selectable: true,
637
+ hasControls: false,
638
+ hasBorders: false,
639
+ hoverCursor: "move",
640
+ lockRotation: true,
641
+ lockScalingX: true,
642
+ lockScalingY: true,
643
+ subTargetCheck: true, // Allow events to pass through if needed, but we treat as one
644
+ interactive: false, // Children not interactive
645
+ // @ts-ignore
646
+ data: {
647
+ type: "feature-marker",
648
+ isGroup: true,
649
+ groupId,
650
+ indices: members.map((m) => m.index),
651
+ },
652
+ });
653
+
654
+ // Auto-hide logic for group
655
+ groupObj.set("opacity", 0);
656
+ groupObj.on("mouseover", () => {
657
+ groupObj.set("opacity", 1);
658
+ canvas.requestRenderAll();
659
+ });
660
+ groupObj.on("mouseout", () => {
661
+ if (canvas.getActiveObject() !== groupObj) {
662
+ groupObj.set("opacity", 0);
663
+ canvas.requestRenderAll();
664
+ }
665
+ });
666
+ groupObj.on("selected", () => {
667
+ groupObj.set("opacity", 1);
668
+ canvas.requestRenderAll();
669
+ });
670
+ groupObj.on("deselected", () => {
671
+ groupObj.set("opacity", 0);
672
+ canvas.requestRenderAll();
673
+ });
674
+
675
+ canvas.add(groupObj);
676
+ canvas.bringObjectToFront(groupObj);
677
+ });
678
+
679
+ this.canvasService.requestRenderAll();
680
+ }
681
+
682
+ private enforceConstraints() {
683
+ if (!this.canvasService || !this.currentGeometry) return;
684
+ // Iterate markers and snap them if geometry changed
685
+ const canvas = this.canvasService.canvas;
686
+ const markers = canvas
687
+ .getObjects()
688
+ .filter((obj: any) => obj.data?.type === "feature-marker");
689
+
690
+ markers.forEach((marker: any) => {
691
+ // Find associated feature
692
+ let feature: EdgeFeature | undefined;
693
+ if (marker.data?.isGroup) {
694
+ const indices = marker.data?.indices as number[];
695
+ if (indices && indices.length > 0) {
696
+ feature = this.features[indices[0]];
697
+ }
698
+ } else {
699
+ const index = marker.data?.index;
700
+ if (index !== undefined) {
701
+ feature = this.features[index];
702
+ }
703
+ }
704
+
705
+ const geometry = this.getGeometryForFeature(
706
+ this.currentGeometry!,
707
+ feature,
708
+ );
709
+
710
+ const markerStrokeWidth = (marker.strokeWidth || 2) * (marker.scaleX || 1);
711
+ const minDim = Math.min(marker.getScaledWidth(), marker.getScaledHeight());
712
+ const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
713
+
714
+ const snapped = this.constrainPosition(
715
+ new Point(marker.left, marker.top),
716
+ geometry,
717
+ limit
718
+ );
719
+ marker.set({ left: snapped.x, top: snapped.y });
720
+ marker.setCoords();
721
+ });
722
+ canvas.requestRenderAll();
723
+ }
724
+ }