@pooder/kit 6.1.1 → 6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @pooder/kit
2
2
 
3
+ ## 6.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - snapping
8
+
3
9
  ## 6.1.1
4
10
 
5
11
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -92,6 +92,11 @@ declare class ImageTool implements Extension {
92
92
  private cropShapeHatchPatternKey?;
93
93
  private imageSpecs;
94
94
  private overlaySpecs;
95
+ private activeSnapX;
96
+ private activeSnapY;
97
+ private snapGuideXObject?;
98
+ private snapGuideYObject?;
99
+ private canvasObjectMovingHandler?;
95
100
  private renderProducerDisposable?;
96
101
  private readonly subscriptions;
97
102
  private imageControlsByCapabilityKey;
@@ -102,6 +107,21 @@ declare class ImageTool implements Extension {
102
107
  private onSelectionCleared;
103
108
  private onSceneLayoutChanged;
104
109
  private onSceneGeometryChanged;
110
+ private bindCanvasInteractionHandlers;
111
+ private unbindCanvasInteractionHandlers;
112
+ private getActiveImageTarget;
113
+ private getTargetBoundsScene;
114
+ private getSnapThresholdScene;
115
+ private pickSnapMatch;
116
+ private computeMoveSnapMatches;
117
+ private areSnapMatchesEqual;
118
+ private updateSnapMatchState;
119
+ private clearSnapGuides;
120
+ private removeSnapGuideObject;
121
+ private createOrUpdateSnapGuideObject;
122
+ private updateSnapGuideVisuals;
123
+ private handleCanvasObjectMoving;
124
+ private applySnapMatchesToTarget;
105
125
  private syncToolActiveFromWorkbench;
106
126
  private isImageEditingVisible;
107
127
  private getEnabledImageControlCapabilities;
package/dist/index.d.ts CHANGED
@@ -92,6 +92,11 @@ declare class ImageTool implements Extension {
92
92
  private cropShapeHatchPatternKey?;
93
93
  private imageSpecs;
94
94
  private overlaySpecs;
95
+ private activeSnapX;
96
+ private activeSnapY;
97
+ private snapGuideXObject?;
98
+ private snapGuideYObject?;
99
+ private canvasObjectMovingHandler?;
95
100
  private renderProducerDisposable?;
96
101
  private readonly subscriptions;
97
102
  private imageControlsByCapabilityKey;
@@ -102,6 +107,21 @@ declare class ImageTool implements Extension {
102
107
  private onSelectionCleared;
103
108
  private onSceneLayoutChanged;
104
109
  private onSceneGeometryChanged;
110
+ private bindCanvasInteractionHandlers;
111
+ private unbindCanvasInteractionHandlers;
112
+ private getActiveImageTarget;
113
+ private getTargetBoundsScene;
114
+ private getSnapThresholdScene;
115
+ private pickSnapMatch;
116
+ private computeMoveSnapMatches;
117
+ private areSnapMatchesEqual;
118
+ private updateSnapMatchState;
119
+ private clearSnapGuides;
120
+ private removeSnapGuideObject;
121
+ private createOrUpdateSnapGuideObject;
122
+ private updateSnapGuideVisuals;
123
+ private handleCanvasObjectMoving;
124
+ private applySnapMatchesToTarget;
105
125
  private syncToolActiveFromWorkbench;
106
126
  private isImageEditingVisible;
107
127
  private getEnabledImageControlCapabilities;
package/dist/index.js CHANGED
@@ -3074,6 +3074,9 @@ var IMAGE_DEFAULT_CONTROL_CAPABILITIES = [
3074
3074
  "rotate",
3075
3075
  "scale"
3076
3076
  ];
3077
+ var IMAGE_MOVE_SNAP_THRESHOLD_PX = 6;
3078
+ var IMAGE_MOVE_SNAP_RELEASE_THRESHOLD_PX = 10;
3079
+ var IMAGE_SNAP_GUIDE_LAYER_ID = "image.snapGuide";
3077
3080
  var IMAGE_CONTROL_DESCRIPTORS = [
3078
3081
  {
3079
3082
  key: "tl",
@@ -3118,12 +3121,15 @@ var ImageTool = class {
3118
3121
  this.renderSeq = 0;
3119
3122
  this.imageSpecs = [];
3120
3123
  this.overlaySpecs = [];
3124
+ this.activeSnapX = null;
3125
+ this.activeSnapY = null;
3121
3126
  this.subscriptions = new SubscriptionBag();
3122
3127
  this.imageControlsByCapabilityKey = /* @__PURE__ */ new Map();
3123
3128
  this.onToolActivated = (event) => {
3124
3129
  const before = this.isToolActive;
3125
3130
  this.syncToolActiveFromWorkbench(event.id);
3126
3131
  if (!this.isToolActive) {
3132
+ this.clearSnapGuides();
3127
3133
  this.setImageFocus(null, {
3128
3134
  syncCanvasSelection: true,
3129
3135
  skipRender: true
@@ -3174,6 +3180,7 @@ var ImageTool = class {
3174
3180
  this.updateImages();
3175
3181
  };
3176
3182
  this.onSelectionCleared = () => {
3183
+ this.clearSnapGuides();
3177
3184
  this.setImageFocus(null, {
3178
3185
  syncCanvasSelection: false,
3179
3186
  skipRender: true
@@ -3182,6 +3189,7 @@ var ImageTool = class {
3182
3189
  this.updateImages();
3183
3190
  };
3184
3191
  this.onSceneLayoutChanged = () => {
3192
+ this.updateSnapGuideVisuals();
3185
3193
  this.updateImages();
3186
3194
  };
3187
3195
  this.onSceneGeometryChanged = () => {
@@ -3196,6 +3204,9 @@ var ImageTool = class {
3196
3204
  if (typeof id !== "string" || layerId !== IMAGE_OBJECT_LAYER_ID) return;
3197
3205
  const frame = this.getFrameRect();
3198
3206
  if (!frame.width || !frame.height) return;
3207
+ const matches = this.computeMoveSnapMatches(target, frame);
3208
+ this.applySnapMatchesToTarget(target, matches);
3209
+ this.clearSnapGuides();
3199
3210
  const center = target.getCenterPoint ? target.getCenterPoint() : new import_fabric2.Point((_c = target.left) != null ? _c : 0, (_d = target.top) != null ? _d : 0);
3200
3211
  const centerScene = this.canvasService ? this.canvasService.toScenePoint({ x: center.x, y: center.y }) : { x: center.x, y: center.y };
3201
3212
  const objectScale = Number.isFinite(target == null ? void 0 : target.scaleX) ? target.scaleX : 1;
@@ -3258,8 +3269,17 @@ var ImageTool = class {
3258
3269
  }),
3259
3270
  { priority: 300 }
3260
3271
  );
3261
- this.subscriptions.on(context.eventBus, "tool:activated", this.onToolActivated);
3262
- this.subscriptions.on(context.eventBus, "object:modified", this.onObjectModified);
3272
+ this.bindCanvasInteractionHandlers();
3273
+ this.subscriptions.on(
3274
+ context.eventBus,
3275
+ "tool:activated",
3276
+ this.onToolActivated
3277
+ );
3278
+ this.subscriptions.on(
3279
+ context.eventBus,
3280
+ "object:modified",
3281
+ this.onObjectModified
3282
+ );
3263
3283
  this.subscriptions.on(
3264
3284
  context.eventBus,
3265
3285
  "selection:created",
@@ -3327,6 +3347,8 @@ var ImageTool = class {
3327
3347
  this.imageSpecs = [];
3328
3348
  this.overlaySpecs = [];
3329
3349
  this.imageControlsByCapabilityKey.clear();
3350
+ this.clearSnapGuides();
3351
+ this.unbindCanvasInteractionHandlers();
3330
3352
  this.clearRenderedImages();
3331
3353
  (_b = this.renderProducerDisposable) == null ? void 0 : _b.dispose();
3332
3354
  this.renderProducerDisposable = void 0;
@@ -3336,6 +3358,266 @@ var ImageTool = class {
3336
3358
  }
3337
3359
  this.context = void 0;
3338
3360
  }
3361
+ bindCanvasInteractionHandlers() {
3362
+ if (!this.canvasService || this.canvasObjectMovingHandler) return;
3363
+ this.canvasObjectMovingHandler = (e) => {
3364
+ this.handleCanvasObjectMoving(e);
3365
+ };
3366
+ this.canvasService.canvas.on(
3367
+ "object:moving",
3368
+ this.canvasObjectMovingHandler
3369
+ );
3370
+ }
3371
+ unbindCanvasInteractionHandlers() {
3372
+ if (!this.canvasService || !this.canvasObjectMovingHandler) return;
3373
+ this.canvasService.canvas.off(
3374
+ "object:moving",
3375
+ this.canvasObjectMovingHandler
3376
+ );
3377
+ this.canvasObjectMovingHandler = void 0;
3378
+ }
3379
+ getActiveImageTarget(target) {
3380
+ var _a, _b;
3381
+ if (!this.isToolActive) return null;
3382
+ if (!target) return null;
3383
+ if (((_a = target == null ? void 0 : target.data) == null ? void 0 : _a.layerId) !== IMAGE_OBJECT_LAYER_ID) return null;
3384
+ if (typeof ((_b = target == null ? void 0 : target.data) == null ? void 0 : _b.id) !== "string") return null;
3385
+ return target;
3386
+ }
3387
+ getTargetBoundsScene(target) {
3388
+ if (!this.canvasService || !target) return null;
3389
+ const rawBounds = typeof target.getBoundingRect === "function" ? target.getBoundingRect() : {
3390
+ left: Number(target.left || 0),
3391
+ top: Number(target.top || 0),
3392
+ width: Number(target.width || 0),
3393
+ height: Number(target.height || 0)
3394
+ };
3395
+ return this.canvasService.toSceneRect({
3396
+ left: Number(rawBounds.left || 0),
3397
+ top: Number(rawBounds.top || 0),
3398
+ width: Number(rawBounds.width || 0),
3399
+ height: Number(rawBounds.height || 0)
3400
+ });
3401
+ }
3402
+ getSnapThresholdScene(px) {
3403
+ if (!this.canvasService) return px;
3404
+ return this.canvasService.toSceneLength(px);
3405
+ }
3406
+ pickSnapMatch(candidates, previous) {
3407
+ if (!candidates.length) return null;
3408
+ const snapThreshold = this.getSnapThresholdScene(
3409
+ IMAGE_MOVE_SNAP_THRESHOLD_PX
3410
+ );
3411
+ const releaseThreshold = this.getSnapThresholdScene(
3412
+ IMAGE_MOVE_SNAP_RELEASE_THRESHOLD_PX
3413
+ );
3414
+ if (previous) {
3415
+ const sticky = candidates.find((candidate) => {
3416
+ return candidate.lineId === previous.lineId && Math.abs(candidate.deltaScene) <= releaseThreshold;
3417
+ });
3418
+ if (sticky) return sticky;
3419
+ }
3420
+ let best = null;
3421
+ candidates.forEach((candidate) => {
3422
+ if (Math.abs(candidate.deltaScene) > snapThreshold) return;
3423
+ if (!best || Math.abs(candidate.deltaScene) < Math.abs(best.deltaScene)) {
3424
+ best = candidate;
3425
+ }
3426
+ });
3427
+ return best;
3428
+ }
3429
+ computeMoveSnapMatches(target, frame) {
3430
+ const bounds = this.getTargetBoundsScene(target);
3431
+ if (!bounds || frame.width <= 0 || frame.height <= 0) {
3432
+ return { x: null, y: null };
3433
+ }
3434
+ const xCandidates = [
3435
+ {
3436
+ axis: "x",
3437
+ lineId: "frame-left",
3438
+ kind: "edge",
3439
+ lineScene: frame.left,
3440
+ deltaScene: frame.left - bounds.left
3441
+ },
3442
+ {
3443
+ axis: "x",
3444
+ lineId: "frame-center-x",
3445
+ kind: "center",
3446
+ lineScene: frame.left + frame.width / 2,
3447
+ deltaScene: frame.left + frame.width / 2 - (bounds.left + bounds.width / 2)
3448
+ },
3449
+ {
3450
+ axis: "x",
3451
+ lineId: "frame-right",
3452
+ kind: "edge",
3453
+ lineScene: frame.left + frame.width,
3454
+ deltaScene: frame.left + frame.width - (bounds.left + bounds.width)
3455
+ }
3456
+ ];
3457
+ const yCandidates = [
3458
+ {
3459
+ axis: "y",
3460
+ lineId: "frame-top",
3461
+ kind: "edge",
3462
+ lineScene: frame.top,
3463
+ deltaScene: frame.top - bounds.top
3464
+ },
3465
+ {
3466
+ axis: "y",
3467
+ lineId: "frame-center-y",
3468
+ kind: "center",
3469
+ lineScene: frame.top + frame.height / 2,
3470
+ deltaScene: frame.top + frame.height / 2 - (bounds.top + bounds.height / 2)
3471
+ },
3472
+ {
3473
+ axis: "y",
3474
+ lineId: "frame-bottom",
3475
+ kind: "edge",
3476
+ lineScene: frame.top + frame.height,
3477
+ deltaScene: frame.top + frame.height - (bounds.top + bounds.height)
3478
+ }
3479
+ ];
3480
+ return {
3481
+ x: this.pickSnapMatch(xCandidates, this.activeSnapX),
3482
+ y: this.pickSnapMatch(yCandidates, this.activeSnapY)
3483
+ };
3484
+ }
3485
+ areSnapMatchesEqual(a, b) {
3486
+ if (!a && !b) return true;
3487
+ if (!a || !b) return false;
3488
+ return a.lineId === b.lineId && a.axis === b.axis && a.kind === b.kind;
3489
+ }
3490
+ updateSnapMatchState(nextX, nextY) {
3491
+ const changed = !this.areSnapMatchesEqual(this.activeSnapX, nextX) || !this.areSnapMatchesEqual(this.activeSnapY, nextY);
3492
+ this.activeSnapX = nextX;
3493
+ this.activeSnapY = nextY;
3494
+ if (changed) {
3495
+ this.updateSnapGuideVisuals();
3496
+ }
3497
+ }
3498
+ clearSnapGuides() {
3499
+ var _a;
3500
+ this.activeSnapX = null;
3501
+ this.activeSnapY = null;
3502
+ this.removeSnapGuideObject("x");
3503
+ this.removeSnapGuideObject("y");
3504
+ (_a = this.canvasService) == null ? void 0 : _a.requestRenderAll();
3505
+ }
3506
+ removeSnapGuideObject(axis) {
3507
+ if (!this.canvasService) return;
3508
+ const canvas = this.canvasService.canvas;
3509
+ const current = axis === "x" ? this.snapGuideXObject : this.snapGuideYObject;
3510
+ if (!current) return;
3511
+ canvas.remove(current);
3512
+ if (axis === "x") {
3513
+ this.snapGuideXObject = void 0;
3514
+ return;
3515
+ }
3516
+ this.snapGuideYObject = void 0;
3517
+ }
3518
+ createOrUpdateSnapGuideObject(axis, pathData) {
3519
+ if (!this.canvasService) return;
3520
+ const canvas = this.canvasService.canvas;
3521
+ const color = this.getConfig("image.control.borderColor", "#1677ff") || "#1677ff";
3522
+ const strokeWidth = 1;
3523
+ this.removeSnapGuideObject(axis);
3524
+ const created = new import_fabric2.Path(pathData, {
3525
+ originX: "left",
3526
+ originY: "top",
3527
+ fill: "rgba(0,0,0,0)",
3528
+ stroke: color,
3529
+ strokeWidth,
3530
+ selectable: false,
3531
+ evented: false,
3532
+ excludeFromExport: true,
3533
+ objectCaching: false,
3534
+ data: {
3535
+ id: `${IMAGE_SNAP_GUIDE_LAYER_ID}.${axis}`,
3536
+ layerId: IMAGE_SNAP_GUIDE_LAYER_ID,
3537
+ type: "image-snap-guide"
3538
+ }
3539
+ });
3540
+ created.setCoords();
3541
+ canvas.add(created);
3542
+ canvas.bringObjectToFront(created);
3543
+ if (axis === "x") {
3544
+ this.snapGuideXObject = created;
3545
+ return;
3546
+ }
3547
+ this.snapGuideYObject = created;
3548
+ }
3549
+ updateSnapGuideVisuals() {
3550
+ if (!this.canvasService || !this.isImageEditingVisible()) {
3551
+ this.removeSnapGuideObject("x");
3552
+ this.removeSnapGuideObject("y");
3553
+ return;
3554
+ }
3555
+ const frame = this.getFrameRect();
3556
+ if (frame.width <= 0 || frame.height <= 0) {
3557
+ this.removeSnapGuideObject("x");
3558
+ this.removeSnapGuideObject("y");
3559
+ return;
3560
+ }
3561
+ const frameScreen = this.getFrameRectScreen(frame);
3562
+ if (this.activeSnapX) {
3563
+ const x = this.canvasService.toScreenPoint({
3564
+ x: this.activeSnapX.lineScene,
3565
+ y: frame.top
3566
+ }).x;
3567
+ this.createOrUpdateSnapGuideObject(
3568
+ "x",
3569
+ `M ${x} ${frameScreen.top} L ${x} ${frameScreen.top + frameScreen.height}`
3570
+ );
3571
+ } else {
3572
+ this.removeSnapGuideObject("x");
3573
+ }
3574
+ if (this.activeSnapY) {
3575
+ const y = this.canvasService.toScreenPoint({
3576
+ x: frame.left,
3577
+ y: this.activeSnapY.lineScene
3578
+ }).y;
3579
+ this.createOrUpdateSnapGuideObject(
3580
+ "y",
3581
+ `M ${frameScreen.left} ${y} L ${frameScreen.left + frameScreen.width} ${y}`
3582
+ );
3583
+ } else {
3584
+ this.removeSnapGuideObject("y");
3585
+ }
3586
+ this.canvasService.requestRenderAll();
3587
+ }
3588
+ handleCanvasObjectMoving(e) {
3589
+ var _a, _b, _c, _d;
3590
+ const target = this.getActiveImageTarget(e == null ? void 0 : e.target);
3591
+ if (!target || !this.canvasService) return;
3592
+ const frame = this.getFrameRect();
3593
+ if (frame.width <= 0 || frame.height <= 0) {
3594
+ this.clearSnapGuides();
3595
+ return;
3596
+ }
3597
+ const matches = this.computeMoveSnapMatches(target, frame);
3598
+ const deltaX = (_b = (_a = matches.x) == null ? void 0 : _a.deltaScene) != null ? _b : 0;
3599
+ const deltaY = (_d = (_c = matches.y) == null ? void 0 : _c.deltaScene) != null ? _d : 0;
3600
+ if (deltaX || deltaY) {
3601
+ target.set({
3602
+ left: Number(target.left || 0) + this.canvasService.toScreenLength(deltaX),
3603
+ top: Number(target.top || 0) + this.canvasService.toScreenLength(deltaY)
3604
+ });
3605
+ target.setCoords();
3606
+ }
3607
+ this.updateSnapMatchState(matches.x, matches.y);
3608
+ }
3609
+ applySnapMatchesToTarget(target, matches) {
3610
+ var _a, _b, _c, _d;
3611
+ if (!this.canvasService || !target) return;
3612
+ const deltaX = (_b = (_a = matches.x) == null ? void 0 : _a.deltaScene) != null ? _b : 0;
3613
+ const deltaY = (_d = (_c = matches.y) == null ? void 0 : _c.deltaScene) != null ? _d : 0;
3614
+ if (!deltaX && !deltaY) return;
3615
+ target.set({
3616
+ left: Number(target.left || 0) + this.canvasService.toScreenLength(deltaX),
3617
+ top: Number(target.top || 0) + this.canvasService.toScreenLength(deltaY)
3618
+ });
3619
+ target.setCoords();
3620
+ }
3339
3621
  syncToolActiveFromWorkbench(fallbackId) {
3340
3622
  var _a;
3341
3623
  const wb = (_a = this.context) == null ? void 0 : _a.services.get("WorkbenchService");
@@ -4322,6 +4604,7 @@ var ImageTool = class {
4322
4604
  isImageSelectionActive: this.isImageSelectionActive,
4323
4605
  focusedImageId: this.focusedImageId
4324
4606
  });
4607
+ this.updateSnapGuideVisuals();
4325
4608
  this.canvasService.requestRenderAll();
4326
4609
  }
4327
4610
  clampNormalized(value) {
package/dist/index.mjs CHANGED
@@ -1025,6 +1025,7 @@ import {
1025
1025
  Canvas as FabricCanvas,
1026
1026
  Control,
1027
1027
  Image as FabricImage2,
1028
+ Path as FabricPath,
1028
1029
  Pattern,
1029
1030
  Point,
1030
1031
  controlsUtils
@@ -1998,6 +1999,9 @@ var IMAGE_DEFAULT_CONTROL_CAPABILITIES = [
1998
1999
  "rotate",
1999
2000
  "scale"
2000
2001
  ];
2002
+ var IMAGE_MOVE_SNAP_THRESHOLD_PX = 6;
2003
+ var IMAGE_MOVE_SNAP_RELEASE_THRESHOLD_PX = 10;
2004
+ var IMAGE_SNAP_GUIDE_LAYER_ID = "image.snapGuide";
2001
2005
  var IMAGE_CONTROL_DESCRIPTORS = [
2002
2006
  {
2003
2007
  key: "tl",
@@ -2042,12 +2046,15 @@ var ImageTool = class {
2042
2046
  this.renderSeq = 0;
2043
2047
  this.imageSpecs = [];
2044
2048
  this.overlaySpecs = [];
2049
+ this.activeSnapX = null;
2050
+ this.activeSnapY = null;
2045
2051
  this.subscriptions = new SubscriptionBag();
2046
2052
  this.imageControlsByCapabilityKey = /* @__PURE__ */ new Map();
2047
2053
  this.onToolActivated = (event) => {
2048
2054
  const before = this.isToolActive;
2049
2055
  this.syncToolActiveFromWorkbench(event.id);
2050
2056
  if (!this.isToolActive) {
2057
+ this.clearSnapGuides();
2051
2058
  this.setImageFocus(null, {
2052
2059
  syncCanvasSelection: true,
2053
2060
  skipRender: true
@@ -2098,6 +2105,7 @@ var ImageTool = class {
2098
2105
  this.updateImages();
2099
2106
  };
2100
2107
  this.onSelectionCleared = () => {
2108
+ this.clearSnapGuides();
2101
2109
  this.setImageFocus(null, {
2102
2110
  syncCanvasSelection: false,
2103
2111
  skipRender: true
@@ -2106,6 +2114,7 @@ var ImageTool = class {
2106
2114
  this.updateImages();
2107
2115
  };
2108
2116
  this.onSceneLayoutChanged = () => {
2117
+ this.updateSnapGuideVisuals();
2109
2118
  this.updateImages();
2110
2119
  };
2111
2120
  this.onSceneGeometryChanged = () => {
@@ -2120,6 +2129,9 @@ var ImageTool = class {
2120
2129
  if (typeof id !== "string" || layerId !== IMAGE_OBJECT_LAYER_ID) return;
2121
2130
  const frame = this.getFrameRect();
2122
2131
  if (!frame.width || !frame.height) return;
2132
+ const matches = this.computeMoveSnapMatches(target, frame);
2133
+ this.applySnapMatchesToTarget(target, matches);
2134
+ this.clearSnapGuides();
2123
2135
  const center = target.getCenterPoint ? target.getCenterPoint() : new Point((_c = target.left) != null ? _c : 0, (_d = target.top) != null ? _d : 0);
2124
2136
  const centerScene = this.canvasService ? this.canvasService.toScenePoint({ x: center.x, y: center.y }) : { x: center.x, y: center.y };
2125
2137
  const objectScale = Number.isFinite(target == null ? void 0 : target.scaleX) ? target.scaleX : 1;
@@ -2182,8 +2194,17 @@ var ImageTool = class {
2182
2194
  }),
2183
2195
  { priority: 300 }
2184
2196
  );
2185
- this.subscriptions.on(context.eventBus, "tool:activated", this.onToolActivated);
2186
- this.subscriptions.on(context.eventBus, "object:modified", this.onObjectModified);
2197
+ this.bindCanvasInteractionHandlers();
2198
+ this.subscriptions.on(
2199
+ context.eventBus,
2200
+ "tool:activated",
2201
+ this.onToolActivated
2202
+ );
2203
+ this.subscriptions.on(
2204
+ context.eventBus,
2205
+ "object:modified",
2206
+ this.onObjectModified
2207
+ );
2187
2208
  this.subscriptions.on(
2188
2209
  context.eventBus,
2189
2210
  "selection:created",
@@ -2251,6 +2272,8 @@ var ImageTool = class {
2251
2272
  this.imageSpecs = [];
2252
2273
  this.overlaySpecs = [];
2253
2274
  this.imageControlsByCapabilityKey.clear();
2275
+ this.clearSnapGuides();
2276
+ this.unbindCanvasInteractionHandlers();
2254
2277
  this.clearRenderedImages();
2255
2278
  (_b = this.renderProducerDisposable) == null ? void 0 : _b.dispose();
2256
2279
  this.renderProducerDisposable = void 0;
@@ -2260,6 +2283,266 @@ var ImageTool = class {
2260
2283
  }
2261
2284
  this.context = void 0;
2262
2285
  }
2286
+ bindCanvasInteractionHandlers() {
2287
+ if (!this.canvasService || this.canvasObjectMovingHandler) return;
2288
+ this.canvasObjectMovingHandler = (e) => {
2289
+ this.handleCanvasObjectMoving(e);
2290
+ };
2291
+ this.canvasService.canvas.on(
2292
+ "object:moving",
2293
+ this.canvasObjectMovingHandler
2294
+ );
2295
+ }
2296
+ unbindCanvasInteractionHandlers() {
2297
+ if (!this.canvasService || !this.canvasObjectMovingHandler) return;
2298
+ this.canvasService.canvas.off(
2299
+ "object:moving",
2300
+ this.canvasObjectMovingHandler
2301
+ );
2302
+ this.canvasObjectMovingHandler = void 0;
2303
+ }
2304
+ getActiveImageTarget(target) {
2305
+ var _a, _b;
2306
+ if (!this.isToolActive) return null;
2307
+ if (!target) return null;
2308
+ if (((_a = target == null ? void 0 : target.data) == null ? void 0 : _a.layerId) !== IMAGE_OBJECT_LAYER_ID) return null;
2309
+ if (typeof ((_b = target == null ? void 0 : target.data) == null ? void 0 : _b.id) !== "string") return null;
2310
+ return target;
2311
+ }
2312
+ getTargetBoundsScene(target) {
2313
+ if (!this.canvasService || !target) return null;
2314
+ const rawBounds = typeof target.getBoundingRect === "function" ? target.getBoundingRect() : {
2315
+ left: Number(target.left || 0),
2316
+ top: Number(target.top || 0),
2317
+ width: Number(target.width || 0),
2318
+ height: Number(target.height || 0)
2319
+ };
2320
+ return this.canvasService.toSceneRect({
2321
+ left: Number(rawBounds.left || 0),
2322
+ top: Number(rawBounds.top || 0),
2323
+ width: Number(rawBounds.width || 0),
2324
+ height: Number(rawBounds.height || 0)
2325
+ });
2326
+ }
2327
+ getSnapThresholdScene(px) {
2328
+ if (!this.canvasService) return px;
2329
+ return this.canvasService.toSceneLength(px);
2330
+ }
2331
+ pickSnapMatch(candidates, previous) {
2332
+ if (!candidates.length) return null;
2333
+ const snapThreshold = this.getSnapThresholdScene(
2334
+ IMAGE_MOVE_SNAP_THRESHOLD_PX
2335
+ );
2336
+ const releaseThreshold = this.getSnapThresholdScene(
2337
+ IMAGE_MOVE_SNAP_RELEASE_THRESHOLD_PX
2338
+ );
2339
+ if (previous) {
2340
+ const sticky = candidates.find((candidate) => {
2341
+ return candidate.lineId === previous.lineId && Math.abs(candidate.deltaScene) <= releaseThreshold;
2342
+ });
2343
+ if (sticky) return sticky;
2344
+ }
2345
+ let best = null;
2346
+ candidates.forEach((candidate) => {
2347
+ if (Math.abs(candidate.deltaScene) > snapThreshold) return;
2348
+ if (!best || Math.abs(candidate.deltaScene) < Math.abs(best.deltaScene)) {
2349
+ best = candidate;
2350
+ }
2351
+ });
2352
+ return best;
2353
+ }
2354
+ computeMoveSnapMatches(target, frame) {
2355
+ const bounds = this.getTargetBoundsScene(target);
2356
+ if (!bounds || frame.width <= 0 || frame.height <= 0) {
2357
+ return { x: null, y: null };
2358
+ }
2359
+ const xCandidates = [
2360
+ {
2361
+ axis: "x",
2362
+ lineId: "frame-left",
2363
+ kind: "edge",
2364
+ lineScene: frame.left,
2365
+ deltaScene: frame.left - bounds.left
2366
+ },
2367
+ {
2368
+ axis: "x",
2369
+ lineId: "frame-center-x",
2370
+ kind: "center",
2371
+ lineScene: frame.left + frame.width / 2,
2372
+ deltaScene: frame.left + frame.width / 2 - (bounds.left + bounds.width / 2)
2373
+ },
2374
+ {
2375
+ axis: "x",
2376
+ lineId: "frame-right",
2377
+ kind: "edge",
2378
+ lineScene: frame.left + frame.width,
2379
+ deltaScene: frame.left + frame.width - (bounds.left + bounds.width)
2380
+ }
2381
+ ];
2382
+ const yCandidates = [
2383
+ {
2384
+ axis: "y",
2385
+ lineId: "frame-top",
2386
+ kind: "edge",
2387
+ lineScene: frame.top,
2388
+ deltaScene: frame.top - bounds.top
2389
+ },
2390
+ {
2391
+ axis: "y",
2392
+ lineId: "frame-center-y",
2393
+ kind: "center",
2394
+ lineScene: frame.top + frame.height / 2,
2395
+ deltaScene: frame.top + frame.height / 2 - (bounds.top + bounds.height / 2)
2396
+ },
2397
+ {
2398
+ axis: "y",
2399
+ lineId: "frame-bottom",
2400
+ kind: "edge",
2401
+ lineScene: frame.top + frame.height,
2402
+ deltaScene: frame.top + frame.height - (bounds.top + bounds.height)
2403
+ }
2404
+ ];
2405
+ return {
2406
+ x: this.pickSnapMatch(xCandidates, this.activeSnapX),
2407
+ y: this.pickSnapMatch(yCandidates, this.activeSnapY)
2408
+ };
2409
+ }
2410
+ areSnapMatchesEqual(a, b) {
2411
+ if (!a && !b) return true;
2412
+ if (!a || !b) return false;
2413
+ return a.lineId === b.lineId && a.axis === b.axis && a.kind === b.kind;
2414
+ }
2415
+ updateSnapMatchState(nextX, nextY) {
2416
+ const changed = !this.areSnapMatchesEqual(this.activeSnapX, nextX) || !this.areSnapMatchesEqual(this.activeSnapY, nextY);
2417
+ this.activeSnapX = nextX;
2418
+ this.activeSnapY = nextY;
2419
+ if (changed) {
2420
+ this.updateSnapGuideVisuals();
2421
+ }
2422
+ }
2423
+ clearSnapGuides() {
2424
+ var _a;
2425
+ this.activeSnapX = null;
2426
+ this.activeSnapY = null;
2427
+ this.removeSnapGuideObject("x");
2428
+ this.removeSnapGuideObject("y");
2429
+ (_a = this.canvasService) == null ? void 0 : _a.requestRenderAll();
2430
+ }
2431
+ removeSnapGuideObject(axis) {
2432
+ if (!this.canvasService) return;
2433
+ const canvas = this.canvasService.canvas;
2434
+ const current = axis === "x" ? this.snapGuideXObject : this.snapGuideYObject;
2435
+ if (!current) return;
2436
+ canvas.remove(current);
2437
+ if (axis === "x") {
2438
+ this.snapGuideXObject = void 0;
2439
+ return;
2440
+ }
2441
+ this.snapGuideYObject = void 0;
2442
+ }
2443
+ createOrUpdateSnapGuideObject(axis, pathData) {
2444
+ if (!this.canvasService) return;
2445
+ const canvas = this.canvasService.canvas;
2446
+ const color = this.getConfig("image.control.borderColor", "#1677ff") || "#1677ff";
2447
+ const strokeWidth = 1;
2448
+ this.removeSnapGuideObject(axis);
2449
+ const created = new FabricPath(pathData, {
2450
+ originX: "left",
2451
+ originY: "top",
2452
+ fill: "rgba(0,0,0,0)",
2453
+ stroke: color,
2454
+ strokeWidth,
2455
+ selectable: false,
2456
+ evented: false,
2457
+ excludeFromExport: true,
2458
+ objectCaching: false,
2459
+ data: {
2460
+ id: `${IMAGE_SNAP_GUIDE_LAYER_ID}.${axis}`,
2461
+ layerId: IMAGE_SNAP_GUIDE_LAYER_ID,
2462
+ type: "image-snap-guide"
2463
+ }
2464
+ });
2465
+ created.setCoords();
2466
+ canvas.add(created);
2467
+ canvas.bringObjectToFront(created);
2468
+ if (axis === "x") {
2469
+ this.snapGuideXObject = created;
2470
+ return;
2471
+ }
2472
+ this.snapGuideYObject = created;
2473
+ }
2474
+ updateSnapGuideVisuals() {
2475
+ if (!this.canvasService || !this.isImageEditingVisible()) {
2476
+ this.removeSnapGuideObject("x");
2477
+ this.removeSnapGuideObject("y");
2478
+ return;
2479
+ }
2480
+ const frame = this.getFrameRect();
2481
+ if (frame.width <= 0 || frame.height <= 0) {
2482
+ this.removeSnapGuideObject("x");
2483
+ this.removeSnapGuideObject("y");
2484
+ return;
2485
+ }
2486
+ const frameScreen = this.getFrameRectScreen(frame);
2487
+ if (this.activeSnapX) {
2488
+ const x = this.canvasService.toScreenPoint({
2489
+ x: this.activeSnapX.lineScene,
2490
+ y: frame.top
2491
+ }).x;
2492
+ this.createOrUpdateSnapGuideObject(
2493
+ "x",
2494
+ `M ${x} ${frameScreen.top} L ${x} ${frameScreen.top + frameScreen.height}`
2495
+ );
2496
+ } else {
2497
+ this.removeSnapGuideObject("x");
2498
+ }
2499
+ if (this.activeSnapY) {
2500
+ const y = this.canvasService.toScreenPoint({
2501
+ x: frame.left,
2502
+ y: this.activeSnapY.lineScene
2503
+ }).y;
2504
+ this.createOrUpdateSnapGuideObject(
2505
+ "y",
2506
+ `M ${frameScreen.left} ${y} L ${frameScreen.left + frameScreen.width} ${y}`
2507
+ );
2508
+ } else {
2509
+ this.removeSnapGuideObject("y");
2510
+ }
2511
+ this.canvasService.requestRenderAll();
2512
+ }
2513
+ handleCanvasObjectMoving(e) {
2514
+ var _a, _b, _c, _d;
2515
+ const target = this.getActiveImageTarget(e == null ? void 0 : e.target);
2516
+ if (!target || !this.canvasService) return;
2517
+ const frame = this.getFrameRect();
2518
+ if (frame.width <= 0 || frame.height <= 0) {
2519
+ this.clearSnapGuides();
2520
+ return;
2521
+ }
2522
+ const matches = this.computeMoveSnapMatches(target, frame);
2523
+ const deltaX = (_b = (_a = matches.x) == null ? void 0 : _a.deltaScene) != null ? _b : 0;
2524
+ const deltaY = (_d = (_c = matches.y) == null ? void 0 : _c.deltaScene) != null ? _d : 0;
2525
+ if (deltaX || deltaY) {
2526
+ target.set({
2527
+ left: Number(target.left || 0) + this.canvasService.toScreenLength(deltaX),
2528
+ top: Number(target.top || 0) + this.canvasService.toScreenLength(deltaY)
2529
+ });
2530
+ target.setCoords();
2531
+ }
2532
+ this.updateSnapMatchState(matches.x, matches.y);
2533
+ }
2534
+ applySnapMatchesToTarget(target, matches) {
2535
+ var _a, _b, _c, _d;
2536
+ if (!this.canvasService || !target) return;
2537
+ const deltaX = (_b = (_a = matches.x) == null ? void 0 : _a.deltaScene) != null ? _b : 0;
2538
+ const deltaY = (_d = (_c = matches.y) == null ? void 0 : _c.deltaScene) != null ? _d : 0;
2539
+ if (!deltaX && !deltaY) return;
2540
+ target.set({
2541
+ left: Number(target.left || 0) + this.canvasService.toScreenLength(deltaX),
2542
+ top: Number(target.top || 0) + this.canvasService.toScreenLength(deltaY)
2543
+ });
2544
+ target.setCoords();
2545
+ }
2263
2546
  syncToolActiveFromWorkbench(fallbackId) {
2264
2547
  var _a;
2265
2548
  const wb = (_a = this.context) == null ? void 0 : _a.services.get("WorkbenchService");
@@ -3246,6 +3529,7 @@ var ImageTool = class {
3246
3529
  isImageSelectionActive: this.isImageSelectionActive,
3247
3530
  focusedImageId: this.focusedImageId
3248
3531
  });
3532
+ this.updateSnapGuideVisuals();
3249
3533
  this.canvasService.requestRenderAll();
3250
3534
  }
3251
3535
  clampNormalized(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pooder/kit",
3
- "version": "6.1.1",
3
+ "version": "6.1.2",
4
4
  "description": "Standard plugins for Pooder editor",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -10,11 +10,16 @@ import {
10
10
  Canvas as FabricCanvas,
11
11
  Control,
12
12
  Image as FabricImage,
13
+ Path as FabricPath,
13
14
  Pattern,
14
15
  Point,
15
16
  controlsUtils,
16
17
  } from "fabric";
17
- import { CanvasService, RenderLayoutRect, RenderObjectSpec } from "../../services";
18
+ import {
19
+ CanvasService,
20
+ RenderLayoutRect,
21
+ RenderObjectSpec,
22
+ } from "../../services";
18
23
  import { isDielineShape, normalizeShapeStyle } from "../dielineShape";
19
24
  import type { DielineShape, DielineShapeStyle } from "../dielineShape";
20
25
  import { generateDielinePath, getPathBounds } from "../geometry";
@@ -140,11 +145,41 @@ interface ImageControlDescriptor {
140
145
  create: () => Control;
141
146
  }
142
147
 
148
+ type SnapAxis = "x" | "y";
149
+ type SnapLineKind = "edge" | "center";
150
+ type SnapLineId =
151
+ | "frame-left"
152
+ | "frame-center-x"
153
+ | "frame-right"
154
+ | "frame-top"
155
+ | "frame-center-y"
156
+ | "frame-bottom";
157
+
158
+ interface SnapMatch {
159
+ axis: SnapAxis;
160
+ lineId: SnapLineId;
161
+ kind: SnapLineKind;
162
+ lineScene: number;
163
+ deltaScene: number;
164
+ }
165
+
166
+ interface SnapCandidate {
167
+ axis: SnapAxis;
168
+ lineId: SnapLineId;
169
+ kind: SnapLineKind;
170
+ lineScene: number;
171
+ deltaScene: number;
172
+ }
173
+
143
174
  const IMAGE_DEFAULT_CONTROL_CAPABILITIES: ImageControlCapability[] = [
144
175
  "rotate",
145
176
  "scale",
146
177
  ];
147
178
 
179
+ const IMAGE_MOVE_SNAP_THRESHOLD_PX = 6;
180
+ const IMAGE_MOVE_SNAP_RELEASE_THRESHOLD_PX = 10;
181
+ const IMAGE_SNAP_GUIDE_LAYER_ID = "image.snapGuide";
182
+
148
183
  const IMAGE_CONTROL_DESCRIPTORS: ImageControlDescriptor[] = [
149
184
  {
150
185
  key: "tl",
@@ -199,6 +234,11 @@ export class ImageTool implements Extension {
199
234
  private cropShapeHatchPatternKey?: string;
200
235
  private imageSpecs: RenderObjectSpec[] = [];
201
236
  private overlaySpecs: RenderObjectSpec[] = [];
237
+ private activeSnapX: SnapMatch | null = null;
238
+ private activeSnapY: SnapMatch | null = null;
239
+ private snapGuideXObject?: FabricPath;
240
+ private snapGuideYObject?: FabricPath;
241
+ private canvasObjectMovingHandler?: (e: any) => void;
202
242
  private renderProducerDisposable?: { dispose: () => void };
203
243
  private readonly subscriptions = new SubscriptionBag();
204
244
  private imageControlsByCapabilityKey: Map<string, Record<string, Control>> =
@@ -247,9 +287,18 @@ export class ImageTool implements Extension {
247
287
  }),
248
288
  { priority: 300 },
249
289
  );
290
+ this.bindCanvasInteractionHandlers();
250
291
 
251
- this.subscriptions.on(context.eventBus, "tool:activated", this.onToolActivated);
252
- this.subscriptions.on(context.eventBus, "object:modified", this.onObjectModified);
292
+ this.subscriptions.on(
293
+ context.eventBus,
294
+ "tool:activated",
295
+ this.onToolActivated,
296
+ );
297
+ this.subscriptions.on(
298
+ context.eventBus,
299
+ "object:modified",
300
+ this.onObjectModified,
301
+ );
253
302
  this.subscriptions.on(
254
303
  context.eventBus,
255
304
  "selection:created",
@@ -328,6 +377,8 @@ export class ImageTool implements Extension {
328
377
  this.imageSpecs = [];
329
378
  this.overlaySpecs = [];
330
379
  this.imageControlsByCapabilityKey.clear();
380
+ this.clearSnapGuides();
381
+ this.unbindCanvasInteractionHandlers();
331
382
 
332
383
  this.clearRenderedImages();
333
384
  this.renderProducerDisposable?.dispose();
@@ -347,6 +398,7 @@ export class ImageTool implements Extension {
347
398
  const before = this.isToolActive;
348
399
  this.syncToolActiveFromWorkbench(event.id);
349
400
  if (!this.isToolActive) {
401
+ this.clearSnapGuides();
350
402
  this.setImageFocus(null, {
351
403
  syncCanvasSelection: true,
352
404
  skipRender: true,
@@ -396,6 +448,7 @@ export class ImageTool implements Extension {
396
448
  };
397
449
 
398
450
  private onSelectionCleared = () => {
451
+ this.clearSnapGuides();
399
452
  this.setImageFocus(null, {
400
453
  syncCanvasSelection: false,
401
454
  skipRender: true,
@@ -405,6 +458,7 @@ export class ImageTool implements Extension {
405
458
  };
406
459
 
407
460
  private onSceneLayoutChanged = () => {
461
+ this.updateSnapGuideVisuals();
408
462
  this.updateImages();
409
463
  };
410
464
 
@@ -412,6 +466,322 @@ export class ImageTool implements Extension {
412
466
  this.updateImages();
413
467
  };
414
468
 
469
+ private bindCanvasInteractionHandlers() {
470
+ if (!this.canvasService || this.canvasObjectMovingHandler) return;
471
+ this.canvasObjectMovingHandler = (e: any) => {
472
+ this.handleCanvasObjectMoving(e);
473
+ };
474
+ this.canvasService.canvas.on(
475
+ "object:moving",
476
+ this.canvasObjectMovingHandler,
477
+ );
478
+ }
479
+
480
+ private unbindCanvasInteractionHandlers() {
481
+ if (!this.canvasService || !this.canvasObjectMovingHandler) return;
482
+ this.canvasService.canvas.off(
483
+ "object:moving",
484
+ this.canvasObjectMovingHandler,
485
+ );
486
+ this.canvasObjectMovingHandler = undefined;
487
+ }
488
+
489
+ private getActiveImageTarget(target: any): any | null {
490
+ if (!this.isToolActive) return null;
491
+ if (!target) return null;
492
+ if (target?.data?.layerId !== IMAGE_OBJECT_LAYER_ID) return null;
493
+ if (typeof target?.data?.id !== "string") return null;
494
+ return target;
495
+ }
496
+
497
+ private getTargetBoundsScene(target: any): FrameRect | null {
498
+ if (!this.canvasService || !target) return null;
499
+ const rawBounds =
500
+ typeof target.getBoundingRect === "function"
501
+ ? target.getBoundingRect()
502
+ : {
503
+ left: Number(target.left || 0),
504
+ top: Number(target.top || 0),
505
+ width: Number(target.width || 0),
506
+ height: Number(target.height || 0),
507
+ };
508
+ return this.canvasService.toSceneRect({
509
+ left: Number(rawBounds.left || 0),
510
+ top: Number(rawBounds.top || 0),
511
+ width: Number(rawBounds.width || 0),
512
+ height: Number(rawBounds.height || 0),
513
+ });
514
+ }
515
+
516
+ private getSnapThresholdScene(px: number): number {
517
+ if (!this.canvasService) return px;
518
+ return this.canvasService.toSceneLength(px);
519
+ }
520
+
521
+ private pickSnapMatch(
522
+ candidates: SnapCandidate[],
523
+ previous: SnapMatch | null,
524
+ ): SnapMatch | null {
525
+ if (!candidates.length) return null;
526
+
527
+ const snapThreshold = this.getSnapThresholdScene(
528
+ IMAGE_MOVE_SNAP_THRESHOLD_PX,
529
+ );
530
+ const releaseThreshold = this.getSnapThresholdScene(
531
+ IMAGE_MOVE_SNAP_RELEASE_THRESHOLD_PX,
532
+ );
533
+
534
+ if (previous) {
535
+ const sticky = candidates.find((candidate) => {
536
+ return (
537
+ candidate.lineId === previous.lineId &&
538
+ Math.abs(candidate.deltaScene) <= releaseThreshold
539
+ );
540
+ });
541
+ if (sticky) return sticky;
542
+ }
543
+
544
+ let best: SnapCandidate | null = null;
545
+ candidates.forEach((candidate) => {
546
+ if (Math.abs(candidate.deltaScene) > snapThreshold) return;
547
+ if (!best || Math.abs(candidate.deltaScene) < Math.abs(best.deltaScene)) {
548
+ best = candidate;
549
+ }
550
+ });
551
+ return best;
552
+ }
553
+
554
+ private computeMoveSnapMatches(
555
+ target: any,
556
+ frame: FrameRect,
557
+ ): { x: SnapMatch | null; y: SnapMatch | null } {
558
+ const bounds = this.getTargetBoundsScene(target);
559
+ if (!bounds || frame.width <= 0 || frame.height <= 0) {
560
+ return { x: null, y: null };
561
+ }
562
+
563
+ const xCandidates: SnapCandidate[] = [
564
+ {
565
+ axis: "x",
566
+ lineId: "frame-left",
567
+ kind: "edge",
568
+ lineScene: frame.left,
569
+ deltaScene: frame.left - bounds.left,
570
+ },
571
+ {
572
+ axis: "x",
573
+ lineId: "frame-center-x",
574
+ kind: "center",
575
+ lineScene: frame.left + frame.width / 2,
576
+ deltaScene:
577
+ frame.left + frame.width / 2 - (bounds.left + bounds.width / 2),
578
+ },
579
+ {
580
+ axis: "x",
581
+ lineId: "frame-right",
582
+ kind: "edge",
583
+ lineScene: frame.left + frame.width,
584
+ deltaScene: frame.left + frame.width - (bounds.left + bounds.width),
585
+ },
586
+ ];
587
+ const yCandidates: SnapCandidate[] = [
588
+ {
589
+ axis: "y",
590
+ lineId: "frame-top",
591
+ kind: "edge",
592
+ lineScene: frame.top,
593
+ deltaScene: frame.top - bounds.top,
594
+ },
595
+ {
596
+ axis: "y",
597
+ lineId: "frame-center-y",
598
+ kind: "center",
599
+ lineScene: frame.top + frame.height / 2,
600
+ deltaScene:
601
+ frame.top + frame.height / 2 - (bounds.top + bounds.height / 2),
602
+ },
603
+ {
604
+ axis: "y",
605
+ lineId: "frame-bottom",
606
+ kind: "edge",
607
+ lineScene: frame.top + frame.height,
608
+ deltaScene: frame.top + frame.height - (bounds.top + bounds.height),
609
+ },
610
+ ];
611
+
612
+ return {
613
+ x: this.pickSnapMatch(xCandidates, this.activeSnapX),
614
+ y: this.pickSnapMatch(yCandidates, this.activeSnapY),
615
+ };
616
+ }
617
+
618
+ private areSnapMatchesEqual(
619
+ a: SnapMatch | null,
620
+ b: SnapMatch | null,
621
+ ): boolean {
622
+ if (!a && !b) return true;
623
+ if (!a || !b) return false;
624
+ return a.lineId === b.lineId && a.axis === b.axis && a.kind === b.kind;
625
+ }
626
+
627
+ private updateSnapMatchState(
628
+ nextX: SnapMatch | null,
629
+ nextY: SnapMatch | null,
630
+ ) {
631
+ const changed =
632
+ !this.areSnapMatchesEqual(this.activeSnapX, nextX) ||
633
+ !this.areSnapMatchesEqual(this.activeSnapY, nextY);
634
+ this.activeSnapX = nextX;
635
+ this.activeSnapY = nextY;
636
+ if (changed) {
637
+ this.updateSnapGuideVisuals();
638
+ }
639
+ }
640
+
641
+ private clearSnapGuides() {
642
+ this.activeSnapX = null;
643
+ this.activeSnapY = null;
644
+ this.removeSnapGuideObject("x");
645
+ this.removeSnapGuideObject("y");
646
+ this.canvasService?.requestRenderAll();
647
+ }
648
+
649
+ private removeSnapGuideObject(axis: SnapAxis) {
650
+ if (!this.canvasService) return;
651
+ const canvas = this.canvasService.canvas;
652
+ const current =
653
+ axis === "x" ? this.snapGuideXObject : this.snapGuideYObject;
654
+ if (!current) return;
655
+ canvas.remove(current);
656
+ if (axis === "x") {
657
+ this.snapGuideXObject = undefined;
658
+ return;
659
+ }
660
+ this.snapGuideYObject = undefined;
661
+ }
662
+
663
+ private createOrUpdateSnapGuideObject(axis: SnapAxis, pathData: string) {
664
+ if (!this.canvasService) return;
665
+ const canvas = this.canvasService.canvas;
666
+ const color =
667
+ this.getConfig<string>("image.control.borderColor", "#1677ff") ||
668
+ "#1677ff";
669
+ const strokeWidth = 1;
670
+ this.removeSnapGuideObject(axis);
671
+
672
+ const created = new FabricPath(pathData, {
673
+ originX: "left",
674
+ originY: "top",
675
+ fill: "rgba(0,0,0,0)",
676
+ stroke: color,
677
+ strokeWidth,
678
+ selectable: false,
679
+ evented: false,
680
+ excludeFromExport: true,
681
+ objectCaching: false,
682
+ data: {
683
+ id: `${IMAGE_SNAP_GUIDE_LAYER_ID}.${axis}`,
684
+ layerId: IMAGE_SNAP_GUIDE_LAYER_ID,
685
+ type: "image-snap-guide",
686
+ },
687
+ } as any);
688
+ created.setCoords();
689
+ canvas.add(created);
690
+ canvas.bringObjectToFront(created);
691
+ if (axis === "x") {
692
+ this.snapGuideXObject = created;
693
+ return;
694
+ }
695
+ this.snapGuideYObject = created;
696
+ }
697
+
698
+ private updateSnapGuideVisuals() {
699
+ if (!this.canvasService || !this.isImageEditingVisible()) {
700
+ this.removeSnapGuideObject("x");
701
+ this.removeSnapGuideObject("y");
702
+ return;
703
+ }
704
+
705
+ const frame = this.getFrameRect();
706
+ if (frame.width <= 0 || frame.height <= 0) {
707
+ this.removeSnapGuideObject("x");
708
+ this.removeSnapGuideObject("y");
709
+ return;
710
+ }
711
+ const frameScreen = this.getFrameRectScreen(frame);
712
+
713
+ if (this.activeSnapX) {
714
+ const x = this.canvasService.toScreenPoint({
715
+ x: this.activeSnapX.lineScene,
716
+ y: frame.top,
717
+ }).x;
718
+ this.createOrUpdateSnapGuideObject(
719
+ "x",
720
+ `M ${x} ${frameScreen.top} L ${x} ${frameScreen.top + frameScreen.height}`,
721
+ );
722
+ } else {
723
+ this.removeSnapGuideObject("x");
724
+ }
725
+
726
+ if (this.activeSnapY) {
727
+ const y = this.canvasService.toScreenPoint({
728
+ x: frame.left,
729
+ y: this.activeSnapY.lineScene,
730
+ }).y;
731
+ this.createOrUpdateSnapGuideObject(
732
+ "y",
733
+ `M ${frameScreen.left} ${y} L ${frameScreen.left + frameScreen.width} ${y}`,
734
+ );
735
+ } else {
736
+ this.removeSnapGuideObject("y");
737
+ }
738
+
739
+ this.canvasService.requestRenderAll();
740
+ }
741
+
742
+ private handleCanvasObjectMoving(e: any) {
743
+ const target = this.getActiveImageTarget(e?.target);
744
+ if (!target || !this.canvasService) return;
745
+
746
+ const frame = this.getFrameRect();
747
+ if (frame.width <= 0 || frame.height <= 0) {
748
+ this.clearSnapGuides();
749
+ return;
750
+ }
751
+
752
+ const matches = this.computeMoveSnapMatches(target, frame);
753
+ const deltaX = matches.x?.deltaScene ?? 0;
754
+ const deltaY = matches.y?.deltaScene ?? 0;
755
+
756
+ if (deltaX || deltaY) {
757
+ target.set({
758
+ left:
759
+ Number(target.left || 0) + this.canvasService.toScreenLength(deltaX),
760
+ top:
761
+ Number(target.top || 0) + this.canvasService.toScreenLength(deltaY),
762
+ });
763
+ target.setCoords();
764
+ }
765
+
766
+ this.updateSnapMatchState(matches.x, matches.y);
767
+ }
768
+
769
+ private applySnapMatchesToTarget(
770
+ target: any,
771
+ matches: { x: SnapMatch | null; y: SnapMatch | null },
772
+ ) {
773
+ if (!this.canvasService || !target) return;
774
+ const deltaX = matches.x?.deltaScene ?? 0;
775
+ const deltaY = matches.y?.deltaScene ?? 0;
776
+ if (!deltaX && !deltaY) return;
777
+
778
+ target.set({
779
+ left: Number(target.left || 0) + this.canvasService.toScreenLength(deltaX),
780
+ top: Number(target.top || 0) + this.canvasService.toScreenLength(deltaY),
781
+ });
782
+ target.setCoords();
783
+ }
784
+
415
785
  private syncToolActiveFromWorkbench(fallbackId?: string | null) {
416
786
  const wb = this.context?.services.get<WorkbenchService>("WorkbenchService");
417
787
  const activeId = wb?.activeToolId;
@@ -1589,6 +1959,7 @@ export class ImageTool implements Extension {
1589
1959
  isImageSelectionActive: this.isImageSelectionActive,
1590
1960
  focusedImageId: this.focusedImageId,
1591
1961
  });
1962
+ this.updateSnapGuideVisuals();
1592
1963
  this.canvasService.requestRenderAll();
1593
1964
  }
1594
1965
 
@@ -1605,6 +1976,9 @@ export class ImageTool implements Extension {
1605
1976
 
1606
1977
  const frame = this.getFrameRect();
1607
1978
  if (!frame.width || !frame.height) return;
1979
+ const matches = this.computeMoveSnapMatches(target, frame);
1980
+ this.applySnapMatchesToTarget(target, matches);
1981
+ this.clearSnapGuides();
1608
1982
 
1609
1983
  const center = target.getCenterPoint
1610
1984
  ? target.getCenterPoint()