kritzel-stencil 0.3.24 → 0.3.26

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 (40) hide show
  1. package/dist/cjs/index.cjs.js +1 -1
  2. package/dist/cjs/kritzel-active-users_45.cjs.entry.js +60 -36
  3. package/dist/cjs/{schema.constants-D8JoUfNc.js → schema.constants-CPz_o--2.js} +271 -101
  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/rotation.handler.js +18 -6
  7. package/dist/collection/classes/managers/anchor.manager.js +112 -38
  8. package/dist/collection/classes/managers/clipboard.manager.js +7 -0
  9. package/dist/collection/classes/objects/selection-group.class.js +80 -5
  10. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +50 -34
  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-B0VgBZUT.js +9 -0
  20. package/dist/components/{p-Bz_zj6dQ.js → p-BAhZcKRe.js} +1 -1
  21. package/dist/components/{p-Dw-t1qQc.js → p-DKxt_AjI.js} +1 -1
  22. package/dist/components/{p-B5XGjTLd.js → p-DRncK1E6.js} +1 -1
  23. package/dist/components/{p-DNDt6O6A.js → p-DUo2M5uM.js} +1 -1
  24. package/dist/esm/index.js +2 -2
  25. package/dist/esm/kritzel-active-users_45.entry.js +60 -36
  26. package/dist/esm/{schema.constants-B3IqnD8l.js → schema.constants-C9ZX7hQv.js} +271 -101
  27. package/dist/stencil/index.esm.js +1 -1
  28. package/dist/stencil/p-71271db1.entry.js +9 -0
  29. package/dist/stencil/{p-B3IqnD8l.js → p-C9ZX7hQv.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/rotation.handler.d.ts +4 -0
  33. package/dist/types/classes/managers/anchor.manager.d.ts +39 -3
  34. package/dist/types/classes/objects/selection-group.class.d.ts +8 -1
  35. package/dist/types/constants/version.d.ts +1 -1
  36. package/dist/types/interfaces/anchor.interface.d.ts +12 -2
  37. package/dist/types/interfaces/engine-state.interface.d.ts +2 -1
  38. package/package.json +1 -1
  39. package/dist/components/p-on11Z8cZ.js +0 -9
  40. package/dist/stencil/p-63bc234f.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;
@@ -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.
@@ -199,15 +207,35 @@ export class KritzelAnchorManager {
199
207
  // Snap Detection
200
208
  // ============================================
201
209
  /**
202
- * Finds the nearest object that can be snapped to within the snap threshold.
210
+ * Finds an object that can be snapped to when the cursor is sufficiently close to its center.
211
+ * Snap is only triggered when the cursor is inside the object's bounds AND within 1/5 of the
212
+ * shorter side from the center, so snapping activates only as the user approaches the center
213
+ * and releases quickly as they move away.
203
214
  * Returns null if no suitable snap target is found.
204
215
  *
205
216
  * @param worldX - X coordinate in world space
206
217
  * @param worldY - Y coordinate in world space
207
218
  * @param excludeLineId - ID of the line being edited (to exclude from snap targets)
208
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.
209
221
  */
210
- findSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId) {
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.
234
+ */
235
+ findSnapPreviewTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId) {
236
+ return this.findBestSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId, () => true);
237
+ }
238
+ findBestSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId, predicate) {
211
239
  let bestTarget = null;
212
240
  let highestZIndex = -Infinity;
213
241
  const objects = this._core.store.allNonSelectionObjects;
@@ -227,20 +255,30 @@ export class KritzelAnchorManager {
227
255
  // Check if point is inside the object's rotated polygon
228
256
  const polygon = object.rotatedPolygon;
229
257
  const points = [polygon.topLeft, polygon.topRight, polygon.bottomRight, polygon.bottomLeft];
230
- if (KritzelGeometryHelper.isPointInPolygon({ x: worldX, y: worldY }, points)) {
231
- // If inside, check if this object is "above" the current best target
232
- if (object.zIndex > highestZIndex) {
233
- highestZIndex = object.zIndex;
234
- bestTarget = {
235
- objectId: object.id,
236
- centerX: object.centerX,
237
- centerY: object.centerY,
238
- };
239
- }
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
+ };
240
271
  }
241
272
  }
242
273
  return bestTarget;
243
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
+ }
244
282
  /**
245
283
  * Sets the current snap candidate for visual feedback during line endpoint dragging.
246
284
  * Updates the store state and triggers a rerender to show the snap indicator.
@@ -251,6 +289,17 @@ export class KritzelAnchorManager {
251
289
  this._core.store.state.snapCandidate = candidate;
252
290
  this._core.rerender();
253
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
+ }
254
303
  /**
255
304
  * Gets the current snap candidate from the store state.
256
305
  *
@@ -259,6 +308,14 @@ export class KritzelAnchorManager {
259
308
  getSnapCandidate() {
260
309
  return this._core.store.state.snapCandidate ?? null;
261
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
+ }
262
319
  /**
263
320
  * Clears the snap candidate from the store state and triggers a rerender.
264
321
  * Should be called when snapping is cancelled or completed.
@@ -267,6 +324,13 @@ export class KritzelAnchorManager {
267
324
  this._core.store.state.snapCandidate = null;
268
325
  this._core.rerender();
269
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
+ }
270
334
  // ============================================
271
335
  // Render Data
272
336
  // ============================================
@@ -320,33 +384,43 @@ export class KritzelAnchorManager {
320
384
  * or null if there's no active snap candidate.
321
385
  */
322
386
  getSnapIndicatorRenderData() {
323
- const snapCandidate = this.getSnapCandidate();
324
- 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)
325
399
  return null;
326
- if (!this.areFiniteNumbers(snapCandidate.centerX, snapCandidate.centerY, snapCandidate.lineEndpointX, snapCandidate.lineEndpointY)) {
400
+ if (!this.areFiniteNumbers(candidate.centerX, candidate.centerY, candidate.lineEndpointX, candidate.lineEndpointY)) {
327
401
  return null;
328
402
  }
329
403
  const scale = this.getSafeScale(this._core.store.state.scale);
330
404
  const indicatorRadius = 8 / scale;
331
405
  const indicatorStrokeWidth = `${2 / scale}`;
332
- const lineStrokeWidthNum = this.areFiniteNumbers(snapCandidate.lineStrokeWidth) && snapCandidate.lineStrokeWidth > 0
333
- ? snapCandidate.lineStrokeWidth
406
+ const lineStrokeWidthNum = this.areFiniteNumbers(candidate.lineStrokeWidth) && candidate.lineStrokeWidth > 0
407
+ ? candidate.lineStrokeWidth
334
408
  : (4 / scale);
335
409
  const lineStrokeWidth = `${lineStrokeWidthNum}`;
336
410
  const dashLength = Math.max(lineStrokeWidthNum * 2, 4 / scale);
337
411
  const dashArray = `${dashLength} ${dashLength}`;
338
- const lineStroke = snapCandidate.lineStroke || '#000000';
339
- let edgeX = this.areFiniteNumbers(snapCandidate.edgeX) ? snapCandidate.edgeX : undefined;
340
- 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;
341
415
  let solidLineEndX = edgeX;
342
416
  let solidLineEndY = edgeY;
343
417
  let arrowPoints;
344
- const arrowOffset = this.areFiniteNumbers(snapCandidate.arrowOffset) && snapCandidate.arrowOffset > 0
345
- ? snapCandidate.arrowOffset
418
+ const arrowOffset = this.areFiniteNumbers(candidate.arrowOffset) && candidate.arrowOffset > 0
419
+ ? candidate.arrowOffset
346
420
  : undefined;
347
421
  if (arrowOffset !== undefined && edgeX !== undefined && edgeY !== undefined) {
348
- const dx = snapCandidate.lineEndpointX - edgeX;
349
- const dy = snapCandidate.lineEndpointY - edgeY;
422
+ const dx = candidate.lineEndpointX - edgeX;
423
+ const dy = candidate.lineEndpointY - edgeY;
350
424
  const length = Math.sqrt(dx * dx + dy * dy);
351
425
  if (length > arrowOffset) {
352
426
  solidLineEndX = edgeX + (dx / length) * arrowOffset;
@@ -354,8 +428,8 @@ export class KritzelAnchorManager {
354
428
  }
355
429
  // Calculate arrow head points
356
430
  // Direction from line endpoint to edge (arrow direction)
357
- const arrowDx = edgeX - snapCandidate.lineEndpointX;
358
- const arrowDy = edgeY - snapCandidate.lineEndpointY;
431
+ const arrowDx = edgeX - candidate.lineEndpointX;
432
+ const arrowDy = edgeY - candidate.lineEndpointY;
359
433
  const arrowLengthTotal = Math.sqrt(arrowDx * arrowDx + arrowDy * arrowDy);
360
434
  if (arrowLengthTotal > 0) {
361
435
  const ux = arrowDx / arrowLengthTotal;
@@ -386,15 +460,15 @@ export class KritzelAnchorManager {
386
460
  return null;
387
461
  }
388
462
  const snapLinePath = (() => {
389
- if (snapCandidate.controlX !== undefined &&
390
- snapCandidate.controlY !== undefined &&
391
- snapCandidate.t !== undefined &&
392
- this.areFiniteNumbers(snapCandidate.controlX, snapCandidate.controlY, snapCandidate.t)) {
393
- 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;
394
468
  // Ensure meaningful range
395
469
  if (startT >= 1)
396
470
  return undefined;
397
- 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);
398
472
  if (!this.areFiniteNumbers(segment.start.x, segment.start.y, segment.control.x, segment.control.y, segment.end.x, segment.end.y)) {
399
473
  return undefined;
400
474
  }
@@ -408,15 +482,15 @@ export class KritzelAnchorManager {
408
482
  lineStrokeWidth,
409
483
  dashArray,
410
484
  lineStroke,
411
- centerX: snapCandidate.centerX,
412
- centerY: snapCandidate.centerY,
413
- lineEndpointX: snapCandidate.lineEndpointX,
414
- lineEndpointY: snapCandidate.lineEndpointY,
485
+ centerX: candidate.centerX,
486
+ centerY: candidate.centerY,
487
+ lineEndpointX: candidate.lineEndpointX,
488
+ lineEndpointY: candidate.lineEndpointY,
415
489
  edgeX,
416
490
  edgeY,
417
491
  arrowOffset,
418
- arrowStyle: snapCandidate.arrowStyle,
419
- arrowFill: snapCandidate.arrowFill,
492
+ arrowStyle: candidate.arrowStyle,
493
+ arrowFill: candidate.arrowFill,
420
494
  solidLineEndX,
421
495
  solidLineEndY,
422
496
  arrowPoints,
@@ -103,6 +103,7 @@ export class KritzelClipboardManager {
103
103
  */
104
104
  regenerateIdsAndRemapAnchors(topLevel) {
105
105
  const all = [];
106
+ const hasOnlyTopLevelLines = topLevel.length > 0 && topLevel.every(object => KritzelClassHelper.isInstanceOf(object, 'KritzelLine'));
106
107
  const collect = (objects) => {
107
108
  for (const object of objects) {
108
109
  all.push(object);
@@ -127,9 +128,15 @@ export class KritzelClipboardManager {
127
128
  if (object.startAnchor && idMap.has(object.startAnchor.objectId)) {
128
129
  object.startAnchor = { objectId: idMap.get(object.startAnchor.objectId) };
129
130
  }
131
+ else if (hasOnlyTopLevelLines) {
132
+ object.startAnchor = undefined;
133
+ }
130
134
  if (object.endAnchor && idMap.has(object.endAnchor.objectId)) {
131
135
  object.endAnchor = { objectId: idMap.get(object.endAnchor.objectId) };
132
136
  }
137
+ else if (hasOnlyTopLevelLines) {
138
+ object.endAnchor = undefined;
139
+ }
133
140
  }
134
141
  }
135
142
  }