@elixpo/lixsketch 5.4.0 → 5.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elixpo/lixsketch",
3
- "version": "5.4.0",
3
+ "version": "5.4.1",
4
4
  "description": "Open-source SVG whiteboard engine with hand-drawn aesthetics — the core of LixSketch",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -75,9 +75,22 @@ let _documentDragActive = false;
75
75
 
76
76
  function _onDocumentDragMove(e) {
77
77
  _lastDragEvent = e;
78
- // Also forward the move to the main handler so the tool keeps updating
79
- // (the SVG won't receive mousemove while cursor is outside it)
80
- handleMainMouseMove(e);
78
+ // Clamp mouse coordinates to SVG bounds before forwarding to tool handlers,
79
+ // so getSVGCoordsFromMouse doesn't produce extreme values when cursor is outside.
80
+ const rect = svg.getBoundingClientRect();
81
+ const clampedX = Math.max(rect.left, Math.min(rect.right, e.clientX));
82
+ const clampedY = Math.max(rect.top, Math.min(rect.bottom, e.clientY));
83
+ const clampedEvent = new MouseEvent(e.type, {
84
+ clientX: clampedX,
85
+ clientY: clampedY,
86
+ buttons: e.buttons,
87
+ button: e.button,
88
+ ctrlKey: e.ctrlKey,
89
+ shiftKey: e.shiftKey,
90
+ altKey: e.altKey,
91
+ metaKey: e.metaKey,
92
+ });
93
+ handleMainMouseMove(clampedEvent);
81
94
  }
82
95
 
83
96
  function _onDocumentDragUp(e) {
@@ -231,6 +244,14 @@ const handleMainMouseDown = (e) => {
231
244
  };
232
245
 
233
246
  const handleMainMouseMove = (e) => {
247
+ // Auto-scroll when dragging near/past viewport edges (checked first so early returns don't skip it)
248
+ if (e.buttons & 1) {
249
+ _lastDragEvent = e;
250
+ _startAutoScroll();
251
+ } else {
252
+ _stopAutoScroll();
253
+ }
254
+
234
255
  if (isSquareToolActive) {
235
256
  handleMouseMoveRect(e);
236
257
  } else if (isArrowToolActive) {
@@ -311,14 +332,6 @@ const handleMainMouseMove = (e) => {
311
332
  handleCodeMouseMove(e);
312
333
  }
313
334
  }
314
-
315
- // Auto-scroll when dragging near/past viewport edges
316
- if (e.buttons & 1) {
317
- _lastDragEvent = e;
318
- _startAutoScroll();
319
- } else {
320
- _stopAutoScroll();
321
- }
322
335
  };
323
336
 
324
337
  const handleMainMouseUp = (e) => {
@@ -151,10 +151,10 @@ function isShapeInSelectionRect(shape, selectionBounds) {
151
151
  break;
152
152
  case 'freehandStroke':
153
153
  shapeBounds = {
154
- x: shape.boundingBox.x,
155
- y: shape.boundingBox.y,
156
- width: shape.boundingBox.width,
157
- height: shape.boundingBox.height
154
+ x: shape.x,
155
+ y: shape.y,
156
+ width: shape.width,
157
+ height: shape.height
158
158
  };
159
159
  break;
160
160
  case 'text':
@@ -389,10 +389,10 @@ class MultiSelection {
389
389
  };
390
390
  case 'freehandStroke':
391
391
  return {
392
- x: shape.boundingBox.x,
393
- y: shape.boundingBox.y,
394
- width: shape.boundingBox.width,
395
- height: shape.boundingBox.height
392
+ x: shape.x,
393
+ y: shape.y,
394
+ width: shape.width,
395
+ height: shape.height
396
396
  };
397
397
  case 'text':
398
398
  const textElement = shape.group ? shape.group.querySelector('text') : null;
@@ -728,7 +728,7 @@ class MultiSelection {
728
728
  shape.updateBoundingBox();
729
729
  }
730
730
 
731
- const boundingBox = shape.boundingBox || this.getShapeBounds(shape);
731
+ const boundingBox = this.getShapeBounds(shape);
732
732
 
733
733
  shapeData = {
734
734
  x: boundingBox.x || 0,
@@ -1360,6 +1360,8 @@ createRotatedControls(angleDiff = 0) {
1360
1360
  if (typeof shape.finalizeMove === 'function') {
1361
1361
  shape.finalizeMove();
1362
1362
  }
1363
+ // Restore isSelected flag (startDrag's removeSelection clears it)
1364
+ shape.isSelected = true;
1363
1365
  });
1364
1366
 
1365
1367
  this.initialPositions.clear();
@@ -1367,6 +1369,9 @@ createRotatedControls(angleDiff = 0) {
1367
1369
  // Push undo for all moved shapes
1368
1370
  this._pushUndoForAll();
1369
1371
 
1372
+ // Refresh bounds and controls after finalizeMove may have changed positions
1373
+ this.updateControls();
1374
+
1370
1375
  if (typeof svg !== 'undefined') {
1371
1376
  svg.style.cursor = 'default';
1372
1377
  }
@@ -1420,28 +1425,26 @@ function handleMultiSelectionMouseDown(e) {
1420
1425
  return true;
1421
1426
  }
1422
1427
 
1423
- if (multiSelection.isPointInBounds(x, y)) {
1424
- // Only start drag if clicking on a shape that's part of the selection
1425
- // This allows clicking through empty areas of the selection rectangle
1426
- let clickedOnSelectedShape = null;
1427
- for (const shape of multiSelection.selectedShapes) {
1428
- if (shape.contains && shape.contains(x, y)) {
1429
- clickedOnSelectedShape = shape;
1430
- break;
1431
- }
1428
+ // Check if clicking on any shape that's part of the selection
1429
+ let clickedOnSelectedShape = null;
1430
+ for (const shape of multiSelection.selectedShapes) {
1431
+ if (shape.contains && shape.contains(x, y)) {
1432
+ clickedOnSelectedShape = shape;
1433
+ break;
1432
1434
  }
1433
- if (clickedOnSelectedShape) {
1434
- // Ctrl+Click: toggle shape out of multi-selection
1435
- if (e.ctrlKey || e.metaKey) {
1436
- multiSelection.removeShape(clickedOnSelectedShape);
1437
- return true;
1438
- }
1439
- multiSelection.startDrag(e);
1435
+ }
1436
+ if (clickedOnSelectedShape) {
1437
+ // Ctrl+Click: toggle shape out of multi-selection
1438
+ if (e.ctrlKey || e.metaKey) {
1439
+ multiSelection.removeShape(clickedOnSelectedShape);
1440
1440
  return true;
1441
1441
  }
1442
- // Click was in bounds but not on a selected shape — fall through
1443
- // to allow clicking on other shapes or starting a new selection
1442
+ multiSelection.startDrag(e);
1443
+ return true;
1444
1444
  }
1445
+
1446
+ // If click is within the selection bounds but not on a shape,
1447
+ // fall through to allow clicking on other shapes or starting a new selection
1445
1448
  }
1446
1449
 
1447
1450
  // Check if clicking on individual shape anchors - let them handle it
@@ -1501,6 +1504,12 @@ function handleMultiSelectionMouseDown(e) {
1501
1504
  return true;
1502
1505
  }
1503
1506
 
1507
+ // If the clicked shape is part of the multi-selection, start dragging instead of clearing
1508
+ if (multiSelection.selectedShapes.size > 1 && multiSelection.selectedShapes.has(clickedShape)) {
1509
+ multiSelection.startDrag(e);
1510
+ return true;
1511
+ }
1512
+
1504
1513
  // If it's the same shape that's already selected, let individual handlers manage it
1505
1514
  if (currentShape === clickedShape) {
1506
1515
  // Clear any other multi-selected shapes so only this one remains selected
@@ -418,6 +418,37 @@ class Arrow {
418
418
  };
419
419
  }
420
420
 
421
+ _updateAnchorPositions() {
422
+ if (!this.anchors || this.anchors.length === 0) return;
423
+
424
+ const anchorSize = 5 / currentZoom;
425
+ let anchorPositions = [this.startPoint, this.endPoint];
426
+
427
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
428
+ const midOnCurve = this.getCubicBezierPoint(0.5);
429
+ anchorPositions.push(midOnCurve);
430
+ } else if (this.arrowCurved === "elbow") {
431
+ const elbowXVal = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
432
+ const midY = (this.startPoint.y + this.endPoint.y) / 2;
433
+ anchorPositions.push({ x: elbowXVal, y: midY });
434
+ } else {
435
+ // straight — offset end anchor past arrowhead
436
+ const arrowAngle = Math.atan2(this.endPoint.y - this.startPoint.y, this.endPoint.x - this.startPoint.x);
437
+ const arrowHeadClearance = this.arrowHeadLength + anchorSize - 10;
438
+ anchorPositions[1] = {
439
+ x: this.endPoint.x + arrowHeadClearance * Math.cos(arrowAngle),
440
+ y: this.endPoint.y + arrowHeadClearance * Math.sin(arrowAngle)
441
+ };
442
+ }
443
+
444
+ anchorPositions.forEach((point, index) => {
445
+ if (this.anchors[index]) {
446
+ this.anchors[index].setAttribute('cx', point.x);
447
+ this.anchors[index].setAttribute('cy', point.y);
448
+ }
449
+ });
450
+ }
451
+
421
452
  _updateLabelElement() {
422
453
  if (!this.label) {
423
454
  if (this.labelElement && this.labelElement.parentNode === this.group) {
@@ -1633,6 +1664,7 @@ static getFrameAttachmentPoint(point, frame, tolerance = 20) {
1633
1664
  this._updatePathElement();
1634
1665
  this._updateHitArea();
1635
1666
  this._updateLabelElement();
1667
+ this._updateAnchorPositions();
1636
1668
 
1637
1669
  // Only update frame containment if we're actively dragging the shape itself
1638
1670
  // and not being moved by a parent frame
@@ -63,11 +63,14 @@ class FreehandStroke {
63
63
  this.group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
64
64
  this.anchors = [];
65
65
  this.rotationAnchor = null;
66
+ this.rotationLine = null;
66
67
  this.selectionPadding = 8;
67
68
  this.selectionOutline = null;
68
69
  this.boundingBox = { x: 0, y: 0, width: 0, height: 0 };
69
70
  this.shapeName = "freehandStroke";
70
-
71
+ this._moveOffsetX = 0;
72
+ this._moveOffsetY = 0;
73
+
71
74
  // Frame attachment properties
72
75
  this.parentFrame = null;
73
76
 
@@ -76,25 +79,25 @@ class FreehandStroke {
76
79
  }
77
80
 
78
81
  // Add position and dimension properties for frame compatibility
82
+ // Getters include pending move offset so callers see the visual position
79
83
  get x() {
80
- return this.boundingBox.x;
84
+ return this.boundingBox.x + (this._moveOffsetX || 0);
81
85
  }
82
-
86
+
83
87
  set x(value) {
84
- const dx = value - this.boundingBox.x;
85
- const dy = 0;
86
- this.points = this.points.map(point => [point[0] + dx, point[1] + dy, point[2] || 0.5]);
87
- this.boundingBox.x = value;
88
+ const dx = value - this.boundingBox.x - (this._moveOffsetX || 0);
89
+ this.points = this.points.map(point => [point[0] + dx, point[1], point[2] || 0.5]);
90
+ this.boundingBox.x = value - (this._moveOffsetX || 0);
88
91
  }
89
-
92
+
90
93
  get y() {
91
- return this.boundingBox.y;
94
+ return this.boundingBox.y + (this._moveOffsetY || 0);
92
95
  }
93
-
96
+
94
97
  set y(value) {
95
- const dy = value - this.boundingBox.y;
98
+ const dy = value - this.boundingBox.y - (this._moveOffsetY || 0);
96
99
  this.points = this.points.map(point => [point[0], point[1] + dy, point[2] || 0.5]);
97
- this.boundingBox.y = value;
100
+ this.boundingBox.y = value - (this._moveOffsetY || 0);
98
101
  }
99
102
 
100
103
  get width() {
@@ -343,14 +346,6 @@ class FreehandStroke {
343
346
  const centerY = this.boundingBox.y + this.boundingBox.height / 2;
344
347
  const rot = this.rotation ? `rotate(${this.rotation} ${centerX} ${centerY})` : '';
345
348
  this.group.setAttribute('transform', `translate(${this._moveOffsetX}, ${this._moveOffsetY}) ${rot}`);
346
-
347
- // Only update frame containment if we're actively dragging the shape itself
348
- // and not being moved by a parent frame
349
- if (isDraggingStroke && !this.isBeingMovedByFrame) {
350
- this.updateFrameContainment();
351
- }
352
-
353
- this.updateAttachedArrows();
354
349
  }
355
350
 
356
351
  // Call after drag ends to bake the offset into actual point coordinates
@@ -366,37 +361,9 @@ class FreehandStroke {
366
361
  }
367
362
 
368
363
  updateAttachedArrows() {
369
- updateArrowsForShape(this);
370
- }
371
-
372
- updateFrameContainment() {
373
- // Don't update if we're being moved by a frame
374
- if (this.isBeingMovedByFrame) return;
375
-
376
- let targetFrame = null;
377
-
378
- // Find which frame this shape is over
379
- shapes.forEach(shape => {
380
- if (shape.shapeName === 'frame' && shape.isShapeInFrame(this)) {
381
- targetFrame = shape;
382
- }
383
- });
384
-
385
- // If we have a parent frame and we're being dragged, temporarily remove clipping
386
- if (this.parentFrame && isDraggingStroke) {
387
- this.parentFrame.temporarilyRemoveFromFrame(this);
388
- }
389
-
390
- // Update frame highlighting
391
- if (hoveredFrameStroke && hoveredFrameStroke !== targetFrame) {
392
- hoveredFrameStroke.removeHighlight();
364
+ if (typeof window.__updateArrowsForShape === 'function') {
365
+ window.__updateArrowsForShape(this);
393
366
  }
394
-
395
- if (targetFrame && targetFrame !== hoveredFrameStroke) {
396
- targetFrame.highlightFrame();
397
- }
398
-
399
- hoveredFrameStroke = targetFrame;
400
367
  }
401
368
 
402
369
  selectStroke() {
@@ -425,11 +392,15 @@ class FreehandStroke {
425
392
  if (this.rotationAnchor && this.rotationAnchor.parentNode === this.group) {
426
393
  this.group.removeChild(this.rotationAnchor);
427
394
  }
395
+ if (this.rotationLine && this.rotationLine.parentNode === this.group) {
396
+ this.group.removeChild(this.rotationLine);
397
+ }
428
398
  if (this.selectionOutline && this.selectionOutline.parentNode === this.group) {
429
399
  this.group.removeChild(this.selectionOutline);
430
400
  }
431
401
  this.anchors = [];
432
402
  this.rotationAnchor = null;
403
+ this.rotationLine = null;
433
404
  this.selectionOutline = null;
434
405
  this.isSelected = false;
435
406
  }
@@ -465,10 +436,14 @@ class FreehandStroke {
465
436
  const buffer = 10;
466
437
  const anchorSize = 10 / currentZoom;
467
438
 
439
+ // Account for pending move offset
440
+ const ox = this._moveOffsetX || 0;
441
+ const oy = this._moveOffsetY || 0;
442
+
468
443
  // Transform the input coordinates to account for rotation
469
- const centerX = this.boundingBox.x + this.boundingBox.width / 2;
470
- const centerY = this.boundingBox.y + this.boundingBox.height / 2;
471
-
444
+ const centerX = this.boundingBox.x + ox + this.boundingBox.width / 2;
445
+ const centerY = this.boundingBox.y + oy + this.boundingBox.height / 2;
446
+
472
447
  // Rotate the mouse coordinates to the shape's local coordinate system
473
448
  const angleRad = -this.rotation * Math.PI / 180;
474
449
  const dx = x - centerX;
@@ -477,8 +452,8 @@ class FreehandStroke {
477
452
  const localY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad) + centerY;
478
453
 
479
454
  // Check resize anchors in local space
480
- const expandedX = this.boundingBox.x - this.selectionPadding;
481
- const expandedY = this.boundingBox.y - this.selectionPadding;
455
+ const expandedX = this.boundingBox.x + ox - this.selectionPadding;
456
+ const expandedY = this.boundingBox.y + oy - this.selectionPadding;
482
457
  const expandedWidth = this.boundingBox.width + 2 * this.selectionPadding;
483
458
  const expandedHeight = this.boundingBox.height + 2 * this.selectionPadding;
484
459
 
@@ -624,6 +599,7 @@ class FreehandStroke {
624
599
  rotationLine.setAttribute('stroke-dasharray', '2 2');
625
600
  rotationLine.setAttribute('style', 'pointer-events: none;');
626
601
  this.group.appendChild(rotationLine);
602
+ this.rotationLine = rotationLine;
627
603
 
628
604
  // Show sidebar when anchors are added (when shape is selected)
629
605
  disableAllSideBars();
@@ -4,6 +4,9 @@ import { pushCreateAction, pushDeleteAction, pushOptionsChangeAction, pushTransf
4
4
  import { updateAttachedArrows as updateArrowsForShape, cleanupAttachments } from './arrowTool.js';
5
5
  import { calculateSnap, clearSnapGuides } from '../core/SnapGuides.js';
6
6
 
7
+ // Expose updateArrowsForShape globally so FreehandStroke.updateAttachedArrows() can call it
8
+ // (FreehandStroke.js is a separate module and cannot import freehandTool's local bindings)
9
+ window.__updateArrowsForShape = updateArrowsForShape;
7
10
 
8
11
  const strokeColors = document.querySelectorAll(".strokeColors span");
9
12
  const strokeThicknesses = document.querySelectorAll(".strokeThickness span");
@@ -80,6 +83,31 @@ function getSvgPathFromStroke(stroke) {
80
83
  return pathData.join(' ');
81
84
  }
82
85
 
86
+ // Frame containment check for a freehand stroke during drag.
87
+ // Lives here (not in FreehandStroke.js) because it needs isDraggingStroke & hoveredFrameStroke.
88
+ function updateFrameContainmentForStroke(shape) {
89
+ if (shape.isBeingMovedByFrame) return;
90
+
91
+ let targetFrame = null;
92
+ shapes.forEach(s => {
93
+ if (s.shapeName === 'frame' && s.isShapeInFrame(shape)) {
94
+ targetFrame = s;
95
+ }
96
+ });
97
+
98
+ if (shape.parentFrame && isDraggingStroke) {
99
+ shape.parentFrame.temporarilyRemoveFromFrame(shape);
100
+ }
101
+
102
+ if (hoveredFrameStroke && hoveredFrameStroke !== targetFrame) {
103
+ hoveredFrameStroke.removeHighlight();
104
+ }
105
+ if (targetFrame && targetFrame !== hoveredFrameStroke) {
106
+ targetFrame.highlightFrame();
107
+ }
108
+ hoveredFrameStroke = targetFrame;
109
+ }
110
+
83
111
  // Delete functionality
84
112
  function deleteCurrentShape() {
85
113
  if (currentShape && currentShape.shapeName === 'freehandStroke') {
@@ -304,6 +332,14 @@ function handleMouseMove(e) {
304
332
  startX = x;
305
333
  startY = y;
306
334
 
335
+ // Frame containment update (must live here where isDraggingStroke is in scope)
336
+ if (!currentShape.isBeingMovedByFrame) {
337
+ updateFrameContainmentForStroke(currentShape);
338
+ }
339
+
340
+ // Update arrows attached to this shape
341
+ currentShape.updateAttachedArrows();
342
+
307
343
  // Snap guides
308
344
  if (window.__sketchStoreApi && window.__sketchStoreApi.getState().snapToObjects) {
309
345
  const snap = calculateSnap(currentShape, e.shiftKey, e.clientX, e.clientY);
@@ -334,7 +370,7 @@ function handleMouseUp(e) {
334
370
  if (currentStroke && currentStroke.points.length >= 2) {
335
371
  currentStroke.draw(); // Redraw with final smoothing
336
372
  pushCreateAction(currentStroke);
337
-
373
+
338
374
  // Check for frame containment and track attachment
339
375
  const finalFrame = hoveredFrameStroke;
340
376
  if (finalFrame) {
@@ -342,6 +378,12 @@ function handleMouseUp(e) {
342
378
  // Track the attachment for undo
343
379
  pushFrameAttachmentAction(finalFrame, currentStroke, 'attach', null);
344
380
  }
381
+
382
+ // Auto-select the drawn stroke and switch to selection tool
383
+ const drawnShape = currentStroke;
384
+ if (window.__sketchStoreApi) window.__sketchStoreApi.setActiveTool('select', { afterDraw: true });
385
+ currentShape = drawnShape;
386
+ currentShape.selectStroke();
345
387
  } else if (currentStroke) {
346
388
  // Remove strokes that are too small
347
389
  shapes.pop();
@@ -349,13 +391,13 @@ function handleMouseUp(e) {
349
391
  currentStroke.group.parentNode.removeChild(currentStroke.group);
350
392
  }
351
393
  }
352
-
394
+
353
395
  // Clear frame highlighting
354
396
  if (hoveredFrameStroke) {
355
397
  hoveredFrameStroke.removeHighlight();
356
398
  hoveredFrameStroke = null;
357
399
  }
358
-
400
+
359
401
  currentStroke = null;
360
402
  }
361
403