kritzel-stencil 0.3.25 → 0.3.27

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.
Files changed (41) hide show
  1. package/dist/cjs/index.cjs.js +1 -1
  2. package/dist/cjs/kritzel-active-users_45.cjs.entry.js +13 -8
  3. package/dist/cjs/{schema.constants-Np7j2Gz4.js → schema.constants-9v4Edzoe.js} +289 -108
  4. package/dist/collection/classes/handlers/line-handle.handler.js +59 -52
  5. package/dist/collection/classes/handlers/move.handler.js +2 -0
  6. package/dist/collection/classes/handlers/resize.handler.js +22 -0
  7. package/dist/collection/classes/handlers/rotation.handler.js +18 -6
  8. package/dist/collection/classes/managers/anchor.manager.js +108 -45
  9. package/dist/collection/classes/objects/selection-group.class.js +80 -5
  10. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +10 -6
  11. package/dist/collection/configs/default-engine-config.js +1 -0
  12. package/dist/collection/constants/version.js +1 -1
  13. package/dist/components/index.js +1 -1
  14. package/dist/components/kritzel-controls.js +1 -1
  15. package/dist/components/kritzel-editor.js +1 -1
  16. package/dist/components/kritzel-engine.js +1 -1
  17. package/dist/components/kritzel-settings.js +1 -1
  18. package/dist/components/kritzel-tool-config.js +1 -1
  19. package/dist/components/{p-B5XGjTLd.js → p-BmKYyPpl.js} +1 -1
  20. package/dist/components/{p-DNDt6O6A.js → p-CRKezkZU.js} +1 -1
  21. package/dist/components/{p-Dw-t1qQc.js → p-DXYkgNAO.js} +1 -1
  22. package/dist/components/{p-DF0S4FqL.js → p-G3xSPZ0x.js} +1 -1
  23. package/dist/components/p-ioK7ttPL.js +9 -0
  24. package/dist/esm/index.js +2 -2
  25. package/dist/esm/kritzel-active-users_45.entry.js +13 -8
  26. package/dist/esm/{schema.constants-1SDhlkfS.js → schema.constants-BT66X_r5.js} +289 -108
  27. package/dist/stencil/index.esm.js +1 -1
  28. package/dist/stencil/p-9ca04088.entry.js +9 -0
  29. package/dist/stencil/{p-1SDhlkfS.js → p-BT66X_r5.js} +1 -1
  30. package/dist/stencil/stencil.esm.js +1 -1
  31. package/dist/types/classes/handlers/line-handle.handler.d.ts +1 -0
  32. package/dist/types/classes/handlers/resize.handler.d.ts +7 -0
  33. package/dist/types/classes/handlers/rotation.handler.d.ts +4 -0
  34. package/dist/types/classes/managers/anchor.manager.d.ts +35 -2
  35. package/dist/types/classes/objects/selection-group.class.d.ts +8 -1
  36. package/dist/types/constants/version.d.ts +1 -1
  37. package/dist/types/interfaces/anchor.interface.d.ts +12 -2
  38. package/dist/types/interfaces/engine-state.interface.d.ts +2 -1
  39. package/package.json +1 -1
  40. package/dist/components/p-6_95PFq4.js +0 -9
  41. package/dist/stencil/p-7ba7df62.entry.js +0 -9
@@ -47,6 +47,7 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
47
47
  this.hasMoved = false;
48
48
  this.currentSnapTarget = null;
49
49
  this._core.anchorManager.clearSnapCandidate();
50
+ this._core.anchorManager.clearSnapPreviewCandidate();
50
51
  }
51
52
  /**
52
53
  * Handles pointer down events to initiate line handle dragging.
@@ -149,15 +150,19 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
149
150
  const worldY = (clientY - this._core.store.state.translateY) / viewportScale;
150
151
  // Get other endpoint's anchor to prevent anchoring both to same object
151
152
  const otherAnchorId = line.endAnchor?.objectId;
153
+ const previewTarget = this._core.anchorManager.findSnapPreviewTarget(worldX, worldY, line.id, otherAnchorId);
152
154
  // Check for snap target
153
- const snapTarget = this._core.anchorManager.findSnapTarget(worldX, worldY, line.id, otherAnchorId);
155
+ const snapTarget = this._core.anchorManager.findSnapTarget(worldX, worldY, line.id, otherAnchorId, event.pointerType);
154
156
  this.currentSnapTarget = snapTarget;
157
+ const otherEndpointWorld = this.lineLocalToWorld(line, this.initialEndX, this.initialEndY);
158
+ let controlWorld = undefined;
159
+ if (line.controlX !== undefined && line.controlY !== undefined) {
160
+ controlWorld = this.lineLocalToWorld(line, line.controlX, line.controlY);
161
+ }
155
162
  if (snapTarget) {
156
163
  // Snap to target center - convert world coords to local
157
164
  const localCoords = this.worldToLineLocal(line, snapTarget.centerX, snapTarget.centerY);
158
165
  this.updateLineEndpoint(line, localCoords.x, localCoords.y, this.initialEndX, this.initialEndY);
159
- // Get the other endpoint's world position for edge intersection calculation
160
- const otherEndpointWorld = this.lineLocalToWorld(line, this.initialEndX, this.initialEndY);
161
166
  // Calculate edge intersection point using AnchorManager to support curves
162
167
  const targetObject = this._core.store.allNonSelectionObjects.find(obj => obj.id === snapTarget.objectId);
163
168
  let edgeX;
@@ -171,30 +176,16 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
171
176
  t = clipInfo.t;
172
177
  }
173
178
  }
174
- // Calculate control point in world coordinates if it exists
175
- let controlWorld = undefined;
176
- if (line.controlX !== undefined && line.controlY !== undefined) {
177
- controlWorld = this.lineLocalToWorld(line, line.controlX, line.controlY);
178
- }
179
179
  // Update snap indicator
180
- this._core.anchorManager.setSnapCandidate({
181
- objectId: snapTarget.objectId,
182
- endpoint: 'start',
183
- centerX: snapTarget.centerX,
184
- centerY: snapTarget.centerY,
185
- lineEndpointX: otherEndpointWorld.x,
186
- lineEndpointY: otherEndpointWorld.y,
187
- controlX: controlWorld?.x,
188
- controlY: controlWorld?.y,
189
- t,
190
- edgeX,
191
- edgeY,
192
- lineStroke: KritzelColorHelper.resolveThemeColor(line.stroke),
193
- lineStrokeWidth: line.strokeWidth / line.scale,
194
- arrowOffset: line.hasStartArrow ? line.getArrowSize('start') / line.scale : undefined,
195
- arrowStyle: line.hasStartArrow ? line.arrows?.start?.style : undefined,
196
- arrowFill: line.hasStartArrow ? line.getArrowFill('start') : undefined,
197
- });
180
+ this._core.anchorManager.setSnapPreviewCandidate(null);
181
+ this._core.anchorManager.setSnapCandidate(this.createIndicatorCandidate(line, 'start', snapTarget, otherEndpointWorld, controlWorld, { edgeX, edgeY, t }));
182
+ }
183
+ else if (previewTarget) {
184
+ const newStartX = this.initialStartX + localDx;
185
+ const newStartY = this.initialStartY + localDy;
186
+ this.updateLineEndpoint(line, newStartX, newStartY, this.initialEndX, this.initialEndY);
187
+ this._core.anchorManager.clearSnapCandidate();
188
+ this._core.anchorManager.setSnapPreviewCandidate(this.createIndicatorCandidate(line, 'start', previewTarget, otherEndpointWorld, controlWorld));
198
189
  }
199
190
  else {
200
191
  // No snap - use regular position
@@ -203,6 +194,7 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
203
194
  this.updateLineEndpoint(line, newStartX, newStartY, this.initialEndX, this.initialEndY);
204
195
  // Clear snap indicator
205
196
  this._core.anchorManager.clearSnapCandidate();
197
+ this._core.anchorManager.clearSnapPreviewCandidate();
206
198
  }
207
199
  }
208
200
  else if (handleType === 'end') {
@@ -211,15 +203,19 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
211
203
  const worldY = (clientY - this._core.store.state.translateY) / viewportScale;
212
204
  // Get other endpoint's anchor to prevent anchoring both to same object
213
205
  const otherAnchorId = line.startAnchor?.objectId;
206
+ const previewTarget = this._core.anchorManager.findSnapPreviewTarget(worldX, worldY, line.id, otherAnchorId);
214
207
  // Check for snap target
215
- const snapTarget = this._core.anchorManager.findSnapTarget(worldX, worldY, line.id, otherAnchorId);
208
+ const snapTarget = this._core.anchorManager.findSnapTarget(worldX, worldY, line.id, otherAnchorId, event.pointerType);
216
209
  this.currentSnapTarget = snapTarget;
210
+ const otherEndpointWorld = this.lineLocalToWorld(line, this.initialStartX, this.initialStartY);
211
+ let controlWorld = undefined;
212
+ if (line.controlX !== undefined && line.controlY !== undefined) {
213
+ controlWorld = this.lineLocalToWorld(line, line.controlX, line.controlY);
214
+ }
217
215
  if (snapTarget) {
218
216
  // Snap to target center - convert world coords to local
219
217
  const localCoords = this.worldToLineLocal(line, snapTarget.centerX, snapTarget.centerY);
220
218
  this.updateLineEndpoint(line, this.initialStartX, this.initialStartY, localCoords.x, localCoords.y);
221
- // Get the other endpoint's world position for edge intersection calculation
222
- const otherEndpointWorld = this.lineLocalToWorld(line, this.initialStartX, this.initialStartY);
223
219
  // Calculate edge intersection point using AnchorManager to support curves
224
220
  const targetObject = this._core.store.allNonSelectionObjects.find(obj => obj.id === snapTarget.objectId);
225
221
  let edgeX;
@@ -233,30 +229,16 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
233
229
  t = clipInfo.t;
234
230
  }
235
231
  }
236
- // Calculate control point in world coordinates if it exists
237
- let controlWorld = undefined;
238
- if (line.controlX !== undefined && line.controlY !== undefined) {
239
- controlWorld = this.lineLocalToWorld(line, line.controlX, line.controlY);
240
- }
241
232
  // Update snap indicator
242
- this._core.anchorManager.setSnapCandidate({
243
- objectId: snapTarget.objectId,
244
- endpoint: 'end',
245
- centerX: snapTarget.centerX,
246
- centerY: snapTarget.centerY,
247
- lineEndpointX: otherEndpointWorld.x,
248
- lineEndpointY: otherEndpointWorld.y,
249
- controlX: controlWorld?.x,
250
- controlY: controlWorld?.y,
251
- t,
252
- edgeX,
253
- edgeY,
254
- lineStroke: KritzelColorHelper.resolveThemeColor(line.stroke),
255
- lineStrokeWidth: line.strokeWidth / line.scale,
256
- arrowOffset: line.hasEndArrow ? line.getArrowSize('end') / line.scale : undefined,
257
- arrowStyle: line.hasEndArrow ? line.arrows?.end?.style : undefined,
258
- arrowFill: line.hasEndArrow ? line.getArrowFill('end') : undefined,
259
- });
233
+ this._core.anchorManager.setSnapPreviewCandidate(null);
234
+ this._core.anchorManager.setSnapCandidate(this.createIndicatorCandidate(line, 'end', snapTarget, otherEndpointWorld, controlWorld, { edgeX, edgeY, t }));
235
+ }
236
+ else if (previewTarget) {
237
+ const newEndX = this.initialEndX + localDx;
238
+ const newEndY = this.initialEndY + localDy;
239
+ this.updateLineEndpoint(line, this.initialStartX, this.initialStartY, newEndX, newEndY);
240
+ this._core.anchorManager.clearSnapCandidate();
241
+ this._core.anchorManager.setSnapPreviewCandidate(this.createIndicatorCandidate(line, 'end', previewTarget, otherEndpointWorld, controlWorld));
260
242
  }
261
243
  else {
262
244
  // No snap - use regular position
@@ -265,9 +247,12 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
265
247
  this.updateLineEndpoint(line, this.initialStartX, this.initialStartY, newEndX, newEndY);
266
248
  // Clear snap indicator
267
249
  this._core.anchorManager.clearSnapCandidate();
250
+ this._core.anchorManager.clearSnapPreviewCandidate();
268
251
  }
269
252
  }
270
253
  else if (handleType === 'center') {
254
+ this._core.anchorManager.clearSnapPreviewCandidate();
255
+ this._core.anchorManager.clearSnapCandidate();
271
256
  const startControlX = this.initialControlX ?? (this.initialStartX + this.initialEndX) / 2;
272
257
  const startControlY = this.initialControlY ?? (this.initialStartY + this.initialEndY) / 2;
273
258
  const newControlX = startControlX + localDx * 2;
@@ -463,4 +448,26 @@ export class KritzelLineHandleHandler extends KritzelBaseHandler {
463
448
  }
464
449
  return null;
465
450
  }
451
+ createIndicatorCandidate(line, endpoint, target, otherEndpointWorld, controlWorld, clipInfo) {
452
+ return {
453
+ objectId: target.objectId,
454
+ endpoint,
455
+ centerX: target.centerX,
456
+ centerY: target.centerY,
457
+ lineEndpointX: otherEndpointWorld.x,
458
+ lineEndpointY: otherEndpointWorld.y,
459
+ controlX: controlWorld?.x,
460
+ controlY: controlWorld?.y,
461
+ t: clipInfo?.t,
462
+ edgeX: clipInfo?.edgeX,
463
+ edgeY: clipInfo?.edgeY,
464
+ lineStroke: KritzelColorHelper.resolveThemeColor(line.stroke),
465
+ lineStrokeWidth: line.strokeWidth / line.scale,
466
+ arrowOffset: endpoint === 'start'
467
+ ? (line.hasStartArrow ? line.getArrowSize('start') / line.scale : undefined)
468
+ : (line.hasEndArrow ? line.getArrowSize('end') / line.scale : undefined),
469
+ arrowStyle: endpoint === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style,
470
+ arrowFill: endpoint === 'start' ? line.getArrowFill('start') : line.getArrowFill('end'),
471
+ };
472
+ }
466
473
  }
@@ -211,6 +211,7 @@ export class KritzelMoveHandler extends KritzelBaseHandler {
211
211
  this._core.store.state.isDragging = false;
212
212
  if (this.hasMoved) {
213
213
  this._core.store.selectionGroup.persistChildren();
214
+ this._core.store.selectionGroup.refreshObjectDimensions(undefined, false);
214
215
  this._core.store.selectionGroup.update();
215
216
  this._core.engine.emitObjectsChange();
216
217
  this._core.store.state.hasObjectsChanged = true;
@@ -223,6 +224,7 @@ export class KritzelMoveHandler extends KritzelBaseHandler {
223
224
  this._core.store.state.isDragging = false;
224
225
  if (this.hasMoved) {
225
226
  this._core.store.selectionGroup.persistChildren();
227
+ this._core.store.selectionGroup.refreshObjectDimensions(undefined, false);
226
228
  this._core.store.selectionGroup.update();
227
229
  this._core.engine.emitObjectsChange();
228
230
  this._core.store.state.hasObjectsChanged = true;
@@ -36,6 +36,22 @@ export class KritzelResizeHandler extends KritzelBaseHandler {
36
36
  this.newSize = { x: 0, y: 0, width: 0, height: 0 };
37
37
  this.hasResized = false;
38
38
  }
39
+ /**
40
+ * Rebase pointer and size baselines to the latest live selection bounds.
41
+ * This prevents drift when anchored lines to external objects expand/shrink
42
+ * the selection box while a resize gesture is in progress.
43
+ */
44
+ rebaseResizeBaseline(selectionGroup, clientX, clientY) {
45
+ this.initialMouseX = clientX;
46
+ this.initialMouseY = clientY;
47
+ this.initialSize.width = selectionGroup.width;
48
+ this.initialSize.height = selectionGroup.height;
49
+ this.initialSize.x = selectionGroup.translateX;
50
+ this.initialSize.y = selectionGroup.translateY;
51
+ }
52
+ shouldRebaseResizeBaseline(selectionGroup) {
53
+ return selectionGroup.hasExternalAnchoredLineConnections?.() === true;
54
+ }
39
55
  /**
40
56
  * Handles the pointer down event to initiate a resize operation.
41
57
  * Captures the initial mouse position and selection group dimensions when
@@ -163,6 +179,9 @@ export class KritzelResizeHandler extends KritzelBaseHandler {
163
179
  this.newSize.x = newCenterX - this.newSize.width / objectScale / 2;
164
180
  this.newSize.y = newCenterY - this.newSize.height / objectScale / 2;
165
181
  selectionGroup.resize(this.newSize.x, this.newSize.y, this.newSize.width, this.newSize.height);
182
+ if (this.shouldRebaseResizeBaseline(selectionGroup)) {
183
+ this.rebaseResizeBaseline(selectionGroup, clientX, clientY);
184
+ }
166
185
  }
167
186
  }
168
187
  if (event.pointerType === 'touch' || event.pointerType === 'pen') {
@@ -229,6 +248,9 @@ export class KritzelResizeHandler extends KritzelBaseHandler {
229
248
  this.newSize.x = newCenterX - this.newSize.width / objectScale / 2;
230
249
  this.newSize.y = newCenterY - this.newSize.height / objectScale / 2;
231
250
  selectionGroup.resize(this.newSize.x, this.newSize.y, this.newSize.width, this.newSize.height);
251
+ if (this.shouldRebaseResizeBaseline(selectionGroup)) {
252
+ this.rebaseResizeBaseline(selectionGroup, clientX, clientY);
253
+ }
232
254
  }
233
255
  }
234
256
  }
@@ -12,6 +12,10 @@ export class KritzelRotationHandler extends KritzelBaseHandler {
12
12
  rotation = 0;
13
13
  /** The rotation value of the selection group before the current rotation operation began. */
14
14
  initialSelectionGroupRotation = 0;
15
+ /** The X coordinate of the rotation pivot (group center) captured when rotation starts, in world space. */
16
+ initialCenterX = 0;
17
+ /** The Y coordinate of the rotation pivot (group center) captured when rotation starts, in world space. */
18
+ initialCenterY = 0;
15
19
  /**
16
20
  * Creates an instance of KritzelRotationHandler.
17
21
  * @param core - The KritzelCore instance that provides access to the store, engine, and other core functionalities.
@@ -26,6 +30,8 @@ export class KritzelRotationHandler extends KritzelBaseHandler {
26
30
  reset() {
27
31
  this.initialRotation = 0;
28
32
  this.rotation = 0;
33
+ this.initialCenterX = 0;
34
+ this.initialCenterY = 0;
29
35
  }
30
36
  /**
31
37
  * Handles the pointer down event to initiate a rotation operation.
@@ -45,6 +51,10 @@ export class KritzelRotationHandler extends KritzelBaseHandler {
45
51
  const objectScale = selectionGroup.scale || 1;
46
52
  const centerX = selectionGroup.translateX + selectionGroup.width / 2 / objectScale;
47
53
  const centerY = selectionGroup.translateY + selectionGroup.height / 2 / objectScale;
54
+ // Capture the pivot once so it stays fixed even if the bounding box grows
55
+ // mid-rotation (e.g. a line anchored to an object outside the selection).
56
+ this.initialCenterX = centerX;
57
+ this.initialCenterY = centerY;
48
58
  const cursorX = (clientX - this._core.store.state.translateX) / this._core.store.state.scale;
49
59
  const cursorY = (clientY - this._core.store.state.translateY) / this._core.store.state.scale;
50
60
  this.initialSelectionGroupRotation = selectionGroup.rotation;
@@ -68,6 +78,10 @@ export class KritzelRotationHandler extends KritzelBaseHandler {
68
78
  const objectScale = selectionGroup.scale || 1;
69
79
  const centerX = selectionGroup.translateX + selectionGroup.width / 2 / objectScale;
70
80
  const centerY = selectionGroup.translateY + selectionGroup.height / 2 / objectScale;
81
+ // Capture the pivot once so it stays fixed even if the bounding box grows
82
+ // mid-rotation (e.g. a line anchored to an object outside the selection).
83
+ this.initialCenterX = centerX;
84
+ this.initialCenterY = centerY;
71
85
  const cursorX = (clientX - this._core.store.state.translateX) / this._core.store.state.scale;
72
86
  const cursorY = (clientY - this._core.store.state.translateY) / this._core.store.state.scale;
73
87
  this.initialSelectionGroupRotation = selectionGroup.rotation;
@@ -92,9 +106,8 @@ export class KritzelRotationHandler extends KritzelBaseHandler {
92
106
  if (this._core.store.state.isRotating && selectionGroup) {
93
107
  const clientX = event.clientX - this._core.store.offsetX;
94
108
  const clientY = event.clientY - this._core.store.offsetY;
95
- const objectScale = selectionGroup.scale || 1;
96
- const groupCenterX = selectionGroup.translateX + selectionGroup.width / 2 / objectScale;
97
- const groupCenterY = selectionGroup.translateY + selectionGroup.height / 2 / objectScale;
109
+ const groupCenterX = this.initialCenterX;
110
+ const groupCenterY = this.initialCenterY;
98
111
  const cursorX = (clientX - this._core.store.state.translateX) / this._core.store.state.scale;
99
112
  const cursorY = (clientY - this._core.store.state.translateY) / this._core.store.state.scale;
100
113
  const currentRotation = Math.atan2(groupCenterY - cursorY, groupCenterX - cursorX);
@@ -112,9 +125,8 @@ export class KritzelRotationHandler extends KritzelBaseHandler {
112
125
  if (this._core.store.state.isRotating && selectionGroup) {
113
126
  const clientX = Math.round(firstTouch.clientX - this._core.store.offsetX);
114
127
  const clientY = Math.round(firstTouch.clientY - this._core.store.offsetY);
115
- const objectScale = selectionGroup.scale || 1;
116
- const groupCenterX = selectionGroup.translateX + selectionGroup.width / 2 / objectScale;
117
- const groupCenterY = selectionGroup.translateY + selectionGroup.height / 2 / objectScale;
128
+ const groupCenterX = this.initialCenterX;
129
+ const groupCenterY = this.initialCenterY;
118
130
  const cursorX = (clientX - this._core.store.state.translateX) / this._core.store.state.scale;
119
131
  const cursorY = (clientY - this._core.store.state.translateY) / this._core.store.state.scale;
120
132
  const currentRotation = Math.atan2(groupCenterY - cursorY, groupCenterX - cursorX);
@@ -143,6 +143,14 @@ export class KritzelAnchorManager {
143
143
  }
144
144
  this.snapEndpointToObject(line, entry.endpoint, objectId);
145
145
  }
146
+ const selectionGroup = this._core.store.selectionGroup;
147
+ if (!selectionGroup || !this._core.store.state.isDragging) {
148
+ return;
149
+ }
150
+ if (selectionGroup.hasExternalAnchoredLineConnections()) {
151
+ // Keep the local drag overlay aligned with reshaped anchored lines without spamming Yjs.
152
+ selectionGroup.refreshObjectDimensions(undefined, true);
153
+ }
146
154
  }
147
155
  /**
148
156
  * Snaps a line endpoint to an object's center position.
@@ -209,8 +217,25 @@ export class KritzelAnchorManager {
209
217
  * @param worldY - Y coordinate in world space
210
218
  * @param excludeLineId - ID of the line being edited (to exclude from snap targets)
211
219
  * @param otherEndpointAnchorId - ID of object anchored to the other endpoint (to prevent same-object anchoring)
220
+ * @param pointerType - The pointer type driving the drag interaction.
221
+ */
222
+ findSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId, pointerType = 'mouse') {
223
+ return this.findBestSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId, object => {
224
+ const snapRadius = this.getSnapRadius(object, pointerType);
225
+ const dx = worldX - object.centerX;
226
+ const dy = worldY - object.centerY;
227
+ const distanceToCenter = Math.sqrt(dx * dx + dy * dy);
228
+ return distanceToCenter <= snapRadius;
229
+ });
230
+ }
231
+ /**
232
+ * Finds the topmost anchorable object currently containing the dragged endpoint.
233
+ * Used to drive preview UI before an actual snap engages.
212
234
  */
213
- findSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId) {
235
+ findSnapPreviewTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId) {
236
+ return this.findBestSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId, () => true);
237
+ }
238
+ findBestSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId, predicate) {
214
239
  let bestTarget = null;
215
240
  let highestZIndex = -Infinity;
216
241
  const objects = this._core.store.allNonSelectionObjects;
@@ -230,28 +255,30 @@ export class KritzelAnchorManager {
230
255
  // Check if point is inside the object's rotated polygon
231
256
  const polygon = object.rotatedPolygon;
232
257
  const points = [polygon.topLeft, polygon.topRight, polygon.bottomRight, polygon.bottomLeft];
233
- if (KritzelGeometryHelper.isPointInPolygon({ x: worldX, y: worldY }, points)) {
234
- // Only snap when the cursor is near the object's center (within 1/5 of the shorter side)
235
- const snapRadius = Math.min(object.width, object.height) / 5;
236
- const dx = worldX - object.centerX;
237
- const dy = worldY - object.centerY;
238
- const distanceToCenter = Math.sqrt(dx * dx + dy * dy);
239
- if (distanceToCenter > snapRadius) {
240
- continue;
241
- }
242
- // If inside and close enough to center, check if this object is "above" the current best target
243
- if (object.zIndex > highestZIndex) {
244
- highestZIndex = object.zIndex;
245
- bestTarget = {
246
- objectId: object.id,
247
- centerX: object.centerX,
248
- centerY: object.centerY,
249
- };
250
- }
258
+ if (!KritzelGeometryHelper.isPointInPolygon({ x: worldX, y: worldY }, points)) {
259
+ continue;
260
+ }
261
+ if (!predicate(object)) {
262
+ continue;
263
+ }
264
+ if (object.zIndex > highestZIndex) {
265
+ highestZIndex = object.zIndex;
266
+ bestTarget = {
267
+ objectId: object.id,
268
+ centerX: object.centerX,
269
+ centerY: object.centerY,
270
+ };
251
271
  }
252
272
  }
253
273
  return bestTarget;
254
274
  }
275
+ getSnapRadius(object, pointerType) {
276
+ const baseRadius = Math.min(object.width, object.height) / 5;
277
+ if (pointerType === 'touch' || pointerType === 'pen') {
278
+ return Math.max(baseRadius * 1.75, 24 / this.getSafeScale(this._core.store.state.scale));
279
+ }
280
+ return baseRadius;
281
+ }
255
282
  /**
256
283
  * Sets the current snap candidate for visual feedback during line endpoint dragging.
257
284
  * Updates the store state and triggers a rerender to show the snap indicator.
@@ -262,6 +289,17 @@ export class KritzelAnchorManager {
262
289
  this._core.store.state.snapCandidate = candidate;
263
290
  this._core.rerender();
264
291
  }
292
+ /**
293
+ * Sets the current snap preview candidate for early visual feedback during line dragging.
294
+ * Preview state must remain separate from active snap state so line clipping and anchor
295
+ * commit semantics only react to actual snaps.
296
+ *
297
+ * @param candidate - The preview candidate object, or null to clear.
298
+ */
299
+ setSnapPreviewCandidate(candidate) {
300
+ this._core.store.state.snapPreviewCandidate = candidate;
301
+ this._core.rerender();
302
+ }
265
303
  /**
266
304
  * Gets the current snap candidate from the store state.
267
305
  *
@@ -270,6 +308,14 @@ export class KritzelAnchorManager {
270
308
  getSnapCandidate() {
271
309
  return this._core.store.state.snapCandidate ?? null;
272
310
  }
311
+ /**
312
+ * Gets the current snap preview candidate from the store state.
313
+ *
314
+ * @returns The current preview candidate if one exists, null otherwise.
315
+ */
316
+ getSnapPreviewCandidate() {
317
+ return this._core.store.state.snapPreviewCandidate ?? null;
318
+ }
273
319
  /**
274
320
  * Clears the snap candidate from the store state and triggers a rerender.
275
321
  * Should be called when snapping is cancelled or completed.
@@ -278,6 +324,13 @@ export class KritzelAnchorManager {
278
324
  this._core.store.state.snapCandidate = null;
279
325
  this._core.rerender();
280
326
  }
327
+ /**
328
+ * Clears the snap preview candidate from the store state and triggers a rerender.
329
+ */
330
+ clearSnapPreviewCandidate() {
331
+ this._core.store.state.snapPreviewCandidate = null;
332
+ this._core.rerender();
333
+ }
281
334
  // ============================================
282
335
  // Render Data
283
336
  // ============================================
@@ -331,33 +384,43 @@ export class KritzelAnchorManager {
331
384
  * or null if there's no active snap candidate.
332
385
  */
333
386
  getSnapIndicatorRenderData() {
334
- const snapCandidate = this.getSnapCandidate();
335
- if (!snapCandidate)
387
+ return this.getIndicatorRenderDataForCandidate(this.getSnapCandidate());
388
+ }
389
+ /**
390
+ * Gets render data for snap preview visualization.
391
+ *
392
+ * @returns SnapIndicatorRenderData for the active preview candidate, or null if none exists.
393
+ */
394
+ getSnapPreviewRenderData() {
395
+ return this.getIndicatorRenderDataForCandidate(this.getSnapPreviewCandidate());
396
+ }
397
+ getIndicatorRenderDataForCandidate(candidate) {
398
+ if (!candidate)
336
399
  return null;
337
- if (!this.areFiniteNumbers(snapCandidate.centerX, snapCandidate.centerY, snapCandidate.lineEndpointX, snapCandidate.lineEndpointY)) {
400
+ if (!this.areFiniteNumbers(candidate.centerX, candidate.centerY, candidate.lineEndpointX, candidate.lineEndpointY)) {
338
401
  return null;
339
402
  }
340
403
  const scale = this.getSafeScale(this._core.store.state.scale);
341
404
  const indicatorRadius = 8 / scale;
342
405
  const indicatorStrokeWidth = `${2 / scale}`;
343
- const lineStrokeWidthNum = this.areFiniteNumbers(snapCandidate.lineStrokeWidth) && snapCandidate.lineStrokeWidth > 0
344
- ? snapCandidate.lineStrokeWidth
406
+ const lineStrokeWidthNum = this.areFiniteNumbers(candidate.lineStrokeWidth) && candidate.lineStrokeWidth > 0
407
+ ? candidate.lineStrokeWidth
345
408
  : (4 / scale);
346
409
  const lineStrokeWidth = `${lineStrokeWidthNum}`;
347
410
  const dashLength = Math.max(lineStrokeWidthNum * 2, 4 / scale);
348
411
  const dashArray = `${dashLength} ${dashLength}`;
349
- const lineStroke = snapCandidate.lineStroke || '#000000';
350
- let edgeX = this.areFiniteNumbers(snapCandidate.edgeX) ? snapCandidate.edgeX : undefined;
351
- let edgeY = this.areFiniteNumbers(snapCandidate.edgeY) ? snapCandidate.edgeY : undefined;
412
+ const lineStroke = candidate.lineStroke || '#000000';
413
+ let edgeX = this.areFiniteNumbers(candidate.edgeX) ? candidate.edgeX : undefined;
414
+ let edgeY = this.areFiniteNumbers(candidate.edgeY) ? candidate.edgeY : undefined;
352
415
  let solidLineEndX = edgeX;
353
416
  let solidLineEndY = edgeY;
354
417
  let arrowPoints;
355
- const arrowOffset = this.areFiniteNumbers(snapCandidate.arrowOffset) && snapCandidate.arrowOffset > 0
356
- ? snapCandidate.arrowOffset
418
+ const arrowOffset = this.areFiniteNumbers(candidate.arrowOffset) && candidate.arrowOffset > 0
419
+ ? candidate.arrowOffset
357
420
  : undefined;
358
421
  if (arrowOffset !== undefined && edgeX !== undefined && edgeY !== undefined) {
359
- const dx = snapCandidate.lineEndpointX - edgeX;
360
- const dy = snapCandidate.lineEndpointY - edgeY;
422
+ const dx = candidate.lineEndpointX - edgeX;
423
+ const dy = candidate.lineEndpointY - edgeY;
361
424
  const length = Math.sqrt(dx * dx + dy * dy);
362
425
  if (length > arrowOffset) {
363
426
  solidLineEndX = edgeX + (dx / length) * arrowOffset;
@@ -365,8 +428,8 @@ export class KritzelAnchorManager {
365
428
  }
366
429
  // Calculate arrow head points
367
430
  // Direction from line endpoint to edge (arrow direction)
368
- const arrowDx = edgeX - snapCandidate.lineEndpointX;
369
- const arrowDy = edgeY - snapCandidate.lineEndpointY;
431
+ const arrowDx = edgeX - candidate.lineEndpointX;
432
+ const arrowDy = edgeY - candidate.lineEndpointY;
370
433
  const arrowLengthTotal = Math.sqrt(arrowDx * arrowDx + arrowDy * arrowDy);
371
434
  if (arrowLengthTotal > 0) {
372
435
  const ux = arrowDx / arrowLengthTotal;
@@ -397,15 +460,15 @@ export class KritzelAnchorManager {
397
460
  return null;
398
461
  }
399
462
  const snapLinePath = (() => {
400
- if (snapCandidate.controlX !== undefined &&
401
- snapCandidate.controlY !== undefined &&
402
- snapCandidate.t !== undefined &&
403
- this.areFiniteNumbers(snapCandidate.controlX, snapCandidate.controlY, snapCandidate.t)) {
404
- const startT = snapCandidate.endpoint === 'start' ? 1 - snapCandidate.t : snapCandidate.t;
463
+ if (candidate.controlX !== undefined &&
464
+ candidate.controlY !== undefined &&
465
+ candidate.t !== undefined &&
466
+ this.areFiniteNumbers(candidate.controlX, candidate.controlY, candidate.t)) {
467
+ const startT = candidate.endpoint === 'start' ? 1 - candidate.t : candidate.t;
405
468
  // Ensure meaningful range
406
469
  if (startT >= 1)
407
470
  return undefined;
408
- const segment = this.extractQuadraticSegment({ x: snapCandidate.lineEndpointX, y: snapCandidate.lineEndpointY }, { x: snapCandidate.controlX, y: snapCandidate.controlY }, { x: snapCandidate.centerX, y: snapCandidate.centerY }, startT, 1);
471
+ const segment = this.extractQuadraticSegment({ x: candidate.lineEndpointX, y: candidate.lineEndpointY }, { x: candidate.controlX, y: candidate.controlY }, { x: candidate.centerX, y: candidate.centerY }, startT, 1);
409
472
  if (!this.areFiniteNumbers(segment.start.x, segment.start.y, segment.control.x, segment.control.y, segment.end.x, segment.end.y)) {
410
473
  return undefined;
411
474
  }
@@ -419,15 +482,15 @@ export class KritzelAnchorManager {
419
482
  lineStrokeWidth,
420
483
  dashArray,
421
484
  lineStroke,
422
- centerX: snapCandidate.centerX,
423
- centerY: snapCandidate.centerY,
424
- lineEndpointX: snapCandidate.lineEndpointX,
425
- lineEndpointY: snapCandidate.lineEndpointY,
485
+ centerX: candidate.centerX,
486
+ centerY: candidate.centerY,
487
+ lineEndpointX: candidate.lineEndpointX,
488
+ lineEndpointY: candidate.lineEndpointY,
426
489
  edgeX,
427
490
  edgeY,
428
491
  arrowOffset,
429
- arrowStyle: snapCandidate.arrowStyle,
430
- arrowFill: snapCandidate.arrowFill,
492
+ arrowStyle: candidate.arrowStyle,
493
+ arrowFill: candidate.arrowFill,
431
494
  solidLineEndX,
432
495
  solidLineEndY,
433
496
  arrowPoints,