kritzel-stencil 0.3.25 → 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 (39) 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-CPz_o--2.js} +267 -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/rotation.handler.js +18 -6
  7. package/dist/collection/classes/managers/anchor.manager.js +108 -45
  8. package/dist/collection/classes/objects/selection-group.class.js +80 -5
  9. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +10 -6
  10. package/dist/collection/configs/default-engine-config.js +1 -0
  11. package/dist/collection/constants/version.js +1 -1
  12. package/dist/components/index.js +1 -1
  13. package/dist/components/kritzel-controls.js +1 -1
  14. package/dist/components/kritzel-editor.js +1 -1
  15. package/dist/components/kritzel-engine.js +1 -1
  16. package/dist/components/kritzel-settings.js +1 -1
  17. package/dist/components/kritzel-tool-config.js +1 -1
  18. package/dist/components/p-B0VgBZUT.js +9 -0
  19. package/dist/components/{p-DF0S4FqL.js → p-BAhZcKRe.js} +1 -1
  20. package/dist/components/{p-Dw-t1qQc.js → p-DKxt_AjI.js} +1 -1
  21. package/dist/components/{p-B5XGjTLd.js → p-DRncK1E6.js} +1 -1
  22. package/dist/components/{p-DNDt6O6A.js → p-DUo2M5uM.js} +1 -1
  23. package/dist/esm/index.js +2 -2
  24. package/dist/esm/kritzel-active-users_45.entry.js +13 -8
  25. package/dist/esm/{schema.constants-1SDhlkfS.js → schema.constants-C9ZX7hQv.js} +267 -108
  26. package/dist/stencil/index.esm.js +1 -1
  27. package/dist/stencil/p-71271db1.entry.js +9 -0
  28. package/dist/stencil/{p-1SDhlkfS.js → p-C9ZX7hQv.js} +1 -1
  29. package/dist/stencil/stencil.esm.js +1 -1
  30. package/dist/types/classes/handlers/line-handle.handler.d.ts +1 -0
  31. package/dist/types/classes/handlers/rotation.handler.d.ts +4 -0
  32. package/dist/types/classes/managers/anchor.manager.d.ts +35 -2
  33. package/dist/types/classes/objects/selection-group.class.d.ts +8 -1
  34. package/dist/types/constants/version.d.ts +1 -1
  35. package/dist/types/interfaces/anchor.interface.d.ts +12 -2
  36. package/dist/types/interfaces/engine-state.interface.d.ts +2 -1
  37. package/package.json +1 -1
  38. package/dist/components/p-6_95PFq4.js +0 -9
  39. 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;
@@ -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,
@@ -39,6 +39,8 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
39
39
  _lastChildPersistTime = 0;
40
40
  /** Minimum interval (ms) between child Yjs persists during drag */
41
41
  static CHILD_PERSIST_THROTTLE_MS = 100;
42
+ /** Ignore tiny floating-point diffs when comparing refreshed bounds */
43
+ static DIMENSION_EPSILON = 0.001;
42
44
  /**
43
45
  * Gets the array of object IDs contained in this selection group.
44
46
  * @returns Array of string IDs for the selected objects
@@ -412,6 +414,30 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
412
414
  }
413
415
  });
414
416
  }
417
+ /**
418
+ * Returns true when the selection contains a line anchored to an object
419
+ * that is not part of this selection group.
420
+ */
421
+ hasExternalAnchoredLineConnections(cachedObjects) {
422
+ const children = cachedObjects ?? this.objects;
423
+ if (children.length === 0) {
424
+ return false;
425
+ }
426
+ const selectedIds = new Set(children.map(child => child.id));
427
+ for (const child of children) {
428
+ if (!KritzelClassHelper.isInstanceOf(child, 'KritzelLine')) {
429
+ continue;
430
+ }
431
+ const line = child;
432
+ if (line.startAnchor && !selectedIds.has(line.startAnchor.objectId)) {
433
+ return true;
434
+ }
435
+ if (line.endAnchor && !selectedIds.has(line.endAnchor.objectId)) {
436
+ return true;
437
+ }
438
+ }
439
+ return false;
440
+ }
415
441
  /**
416
442
  * Resizes the selection group and scales all contained objects proportionally.
417
443
  * Uses snapshot values to avoid compounding errors during continuous drag operations.
@@ -504,8 +530,13 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
504
530
  */
505
531
  rotate(value) {
506
532
  this.rotation = value;
507
- const centerX = this.translateX + this.totalWidth / 2 / this.scale;
508
- const centerY = this.translateY + this.totalHeight / 2 / this.scale;
533
+ // Use the snapshot center as the rotation pivot rather than the live center.
534
+ // refreshObjectDimensions() below can grow the box when an anchored line is pinned
535
+ // to an external object; keeping the pivot fixed prevents the children from drifting.
536
+ const snapshotTotalWidth = this.snapshotWidth + this.padding * 2;
537
+ const snapshotTotalHeight = this.snapshotHeight + this.padding * 2;
538
+ const centerX = this.snapshotTranslateX + snapshotTotalWidth / 2 / this.scale;
539
+ const centerY = this.snapshotTranslateY + snapshotTotalHeight / 2 / this.scale;
509
540
  const angle = value - this.snapshotRotation;
510
541
  const cos = Math.cos(angle);
511
542
  const sin = Math.sin(angle);
@@ -527,6 +558,31 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
527
558
  child.translateY = centerY + rotatedY - child.totalHeight / 2 / child.scale;
528
559
  child.rotate(childCount === 1 ? value : unchangedSnapshot.rotation + angle);
529
560
  }
561
+ // Re-sync any anchored lines so their stored geometry follows the rotated objects
562
+ // (including lines anchored to an object outside this selection), then grow the
563
+ // selection box to enclose the reshaped lines. Rotation sets state.isRotating
564
+ // rather than state.isDragging, so the refresh must be triggered explicitly here.
565
+ const anchorTargetIds = new Set();
566
+ for (const child of children) {
567
+ anchorTargetIds.add(child.id);
568
+ if (KritzelClassHelper.isInstanceOf(child, 'KritzelLine')) {
569
+ const line = child;
570
+ if (line.startAnchor)
571
+ anchorTargetIds.add(line.startAnchor.objectId);
572
+ if (line.endAnchor)
573
+ anchorTargetIds.add(line.endAnchor.objectId);
574
+ }
575
+ }
576
+ let hasAnchoredLines = false;
577
+ for (const targetId of anchorTargetIds) {
578
+ if (this._core.anchorManager.getLinesAnchoredTo(targetId).length > 0) {
579
+ hasAnchoredLines = true;
580
+ this._core.anchorManager.updateAnchorsForObject(targetId);
581
+ }
582
+ }
583
+ if (hasAnchoredLines) {
584
+ this.refreshObjectDimensions(children);
585
+ }
530
586
  });
531
587
  }
532
588
  /**
@@ -556,6 +612,16 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
556
612
  * @param skipPersist If true, only updates local state without persisting to Yjs (used during undo/redo)
557
613
  */
558
614
  refreshObjectDimensions(cachedObjects, skipPersist = false) {
615
+ const previous = {
616
+ minX: this.minX,
617
+ minY: this.minY,
618
+ maxX: this.maxX,
619
+ maxY: this.maxY,
620
+ width: this.width,
621
+ height: this.height,
622
+ translateX: this.translateX,
623
+ translateY: this.translateY,
624
+ };
559
625
  const children = cachedObjects ?? this.objects;
560
626
  if (children.length === 1) {
561
627
  const obj = children[0];
@@ -609,9 +675,18 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
609
675
  this.translateX = cx - (this.width / this.scale + 2 * this.padding) / 2;
610
676
  this.translateY = cy - (this.height / this.scale + 2 * this.padding) / 2;
611
677
  }
612
- if (!skipPersist) {
678
+ const hasChanged = Math.abs(this.minX - previous.minX) > KritzelSelectionGroup.DIMENSION_EPSILON ||
679
+ Math.abs(this.minY - previous.minY) > KritzelSelectionGroup.DIMENSION_EPSILON ||
680
+ Math.abs(this.maxX - previous.maxX) > KritzelSelectionGroup.DIMENSION_EPSILON ||
681
+ Math.abs(this.maxY - previous.maxY) > KritzelSelectionGroup.DIMENSION_EPSILON ||
682
+ Math.abs(this.width - previous.width) > KritzelSelectionGroup.DIMENSION_EPSILON ||
683
+ Math.abs(this.height - previous.height) > KritzelSelectionGroup.DIMENSION_EPSILON ||
684
+ Math.abs(this.translateX - previous.translateX) > KritzelSelectionGroup.DIMENSION_EPSILON ||
685
+ Math.abs(this.translateY - previous.translateY) > KritzelSelectionGroup.DIMENSION_EPSILON;
686
+ if (!skipPersist && hasChanged) {
613
687
  this._core.store.objects.update(this);
614
688
  }
689
+ return hasChanged;
615
690
  }
616
691
  /**
617
692
  * Calculates the horizontal offset from the group's center to an object's center using snapshot data.
@@ -621,7 +696,7 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
621
696
  */
622
697
  getOffsetXToCenterFromSnapshot(snapshot) {
623
698
  const objCenterX = snapshot.translateX + snapshot.totalWidth / snapshot.scale / 2;
624
- const groupCenterX = this.translateX + this.totalWidth / this.scale / 2;
699
+ const groupCenterX = this.snapshotTranslateX + (this.snapshotWidth + this.padding * 2) / this.scale / 2;
625
700
  return objCenterX - groupCenterX;
626
701
  }
627
702
  /**
@@ -632,7 +707,7 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
632
707
  */
633
708
  getOffsetYToCenterFromSnapshot(snapshot) {
634
709
  const objCenterY = snapshot.translateY + snapshot.totalHeight / snapshot.scale / 2;
635
- const groupCenterY = this.translateY + this.totalHeight / this.scale / 2;
710
+ const groupCenterY = this.snapshotTranslateY + (this.snapshotHeight + this.padding * 2) / this.scale / 2;
636
711
  return objCenterY - groupCenterY;
637
712
  }
638
713
  /**