@elixpo/lixsketch 4.6.3 → 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": "4.6.3",
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",
@@ -14,6 +14,94 @@ import { handleMultiSelectionMouseDown, handleMultiSelectionMouseMove, handleMul
14
14
  import { handleMouseDownIcon, handleMouseMoveIcon, handleMouseUpIcon } from '../tools/iconTool.js';
15
15
  import { handleCodeMouseDown, handleCodeMouseMove, handleCodeMouseUp } from '../tools/codeTool.js';
16
16
 
17
+ // === Auto-scroll when dragging near viewport edges ===
18
+ const EDGE_THRESHOLD = 40; // px from edge to start scrolling
19
+ const SCROLL_SPEED = 8; // base px per frame (scaled by zoom)
20
+ let _autoScrollRAF = null;
21
+
22
+ function _autoScroll(e) {
23
+ if (!(e.buttons & 1)) { _stopAutoScroll(); return; } // no primary button
24
+ if (typeof currentViewBox === 'undefined' || typeof currentZoom === 'undefined') return;
25
+ if (typeof isPanning !== 'undefined' && isPanning) return; // don't fight pan tool
26
+ if (typeof isPanningToolActive !== 'undefined' && isPanningToolActive) return;
27
+
28
+ const rect = svg.getBoundingClientRect();
29
+ const mx = e.clientX - rect.left;
30
+ const my = e.clientY - rect.top;
31
+
32
+ let dx = 0, dy = 0;
33
+ if (mx < EDGE_THRESHOLD) dx = -SCROLL_SPEED * (1 - mx / EDGE_THRESHOLD);
34
+ else if (mx > rect.width - EDGE_THRESHOLD) dx = SCROLL_SPEED * (1 - (rect.width - mx) / EDGE_THRESHOLD);
35
+ if (my < EDGE_THRESHOLD) dy = -SCROLL_SPEED * (1 - my / EDGE_THRESHOLD);
36
+ else if (my > rect.height - EDGE_THRESHOLD) dy = SCROLL_SPEED * (1 - (rect.height - my) / EDGE_THRESHOLD);
37
+
38
+ // Also scroll when cursor is outside the canvas entirely
39
+ if (e.clientX < rect.left) dx = -SCROLL_SPEED;
40
+ else if (e.clientX > rect.right) dx = SCROLL_SPEED;
41
+ if (e.clientY < rect.top) dy = -SCROLL_SPEED;
42
+ else if (e.clientY > rect.bottom) dy = SCROLL_SPEED;
43
+
44
+ if (dx === 0 && dy === 0) { _stopAutoScroll(); return; }
45
+
46
+ // Scale speed inversely with zoom so scrolling feels consistent
47
+ const scale = 1 / currentZoom;
48
+ currentViewBox.x += dx * scale;
49
+ currentViewBox.y += dy * scale;
50
+ svg.setAttribute('viewBox', `${currentViewBox.x} ${currentViewBox.y} ${currentViewBox.width} ${currentViewBox.height}`);
51
+ if (typeof freehandCanvas !== 'undefined' && freehandCanvas !== svg) {
52
+ freehandCanvas.setAttribute('viewBox', `${currentViewBox.x} ${currentViewBox.y} ${currentViewBox.width} ${currentViewBox.height}`);
53
+ }
54
+ }
55
+
56
+ function _startAutoScroll() {
57
+ if (_autoScrollRAF) return;
58
+ const tick = () => {
59
+ if (_lastDragEvent) _autoScroll(_lastDragEvent);
60
+ _autoScrollRAF = requestAnimationFrame(tick);
61
+ };
62
+ _autoScrollRAF = requestAnimationFrame(tick);
63
+ }
64
+
65
+ function _stopAutoScroll() {
66
+ if (_autoScrollRAF) {
67
+ cancelAnimationFrame(_autoScrollRAF);
68
+ _autoScrollRAF = null;
69
+ }
70
+ _lastDragEvent = null;
71
+ }
72
+
73
+ let _lastDragEvent = null;
74
+ let _documentDragActive = false;
75
+
76
+ function _onDocumentDragMove(e) {
77
+ _lastDragEvent = 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);
94
+ }
95
+
96
+ function _onDocumentDragUp(e) {
97
+ _stopAutoScroll();
98
+ document.removeEventListener('mousemove', _onDocumentDragMove);
99
+ document.removeEventListener('mouseup', _onDocumentDragUp);
100
+ _documentDragActive = false;
101
+ // Finalize the operation
102
+ handleMainMouseUp(e);
103
+ }
104
+
17
105
  const handleMainMouseDown = (e) => {
18
106
  // Safety: remove any stray selection rectangle from a previous interrupted drag
19
107
  removeMultiSelectionRect();
@@ -156,6 +244,14 @@ const handleMainMouseDown = (e) => {
156
244
  };
157
245
 
158
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
+
159
255
  if (isSquareToolActive) {
160
256
  handleMouseMoveRect(e);
161
257
  } else if (isArrowToolActive) {
@@ -239,6 +335,7 @@ const handleMainMouseMove = (e) => {
239
335
  };
240
336
 
241
337
  const handleMainMouseUp = (e) => {
338
+ _stopAutoScroll();
242
339
  if (isSquareToolActive) {
243
340
  handleMouseUpRect(e);
244
341
  } else if (isArrowToolActive) {
@@ -318,6 +415,21 @@ const handleMainMouseUp = (e) => {
318
415
  };
319
416
 
320
417
  const handleMainMouseLeave = (e) => {
418
+ // If the user is still holding the primary button (dragging outside the canvas),
419
+ // keep auto-scrolling and don't finalize the operation yet.
420
+ if (e.buttons & 1) {
421
+ _lastDragEvent = e;
422
+ _startAutoScroll();
423
+
424
+ // Listen on document to continue receiving move events outside the SVG
425
+ if (!_documentDragActive) {
426
+ _documentDragActive = true;
427
+ document.addEventListener('mousemove', _onDocumentDragMove);
428
+ document.addEventListener('mouseup', _onDocumentDragUp);
429
+ }
430
+ return;
431
+ }
432
+
321
433
  // Stop all active drawing tools when pointer leaves the canvas
322
434
 
323
435
  // Fire mouseUp for whichever tool is active to finalize/cancel the operation
@@ -343,6 +455,15 @@ const handleMainMouseLeave = (e) => {
343
455
 
344
456
  let _boundSvg = null;
345
457
 
458
+ function _onMouseEnter(e) {
459
+ // Cursor re-entered the SVG — stop document-level tracking, SVG handles events again
460
+ if (_documentDragActive) {
461
+ document.removeEventListener('mousemove', _onDocumentDragMove);
462
+ document.removeEventListener('mouseup', _onDocumentDragUp);
463
+ _documentDragActive = false;
464
+ }
465
+ }
466
+
346
467
  function initEventDispatcher(svgEl) {
347
468
  if (_boundSvg) cleanupEventDispatcher();
348
469
  const target = svgEl || svg;
@@ -350,15 +471,23 @@ function initEventDispatcher(svgEl) {
350
471
  target.addEventListener('mousemove', handleMainMouseMove);
351
472
  target.addEventListener('mouseup', handleMainMouseUp);
352
473
  target.addEventListener('mouseleave', handleMainMouseLeave);
474
+ target.addEventListener('mouseenter', _onMouseEnter);
353
475
  _boundSvg = target;
354
476
  }
355
477
 
356
478
  function cleanupEventDispatcher() {
479
+ _stopAutoScroll();
480
+ if (_documentDragActive) {
481
+ document.removeEventListener('mousemove', _onDocumentDragMove);
482
+ document.removeEventListener('mouseup', _onDocumentDragUp);
483
+ _documentDragActive = false;
484
+ }
357
485
  if (_boundSvg) {
358
486
  _boundSvg.removeEventListener('mousedown', handleMainMouseDown);
359
487
  _boundSvg.removeEventListener('mousemove', handleMainMouseMove);
360
488
  _boundSvg.removeEventListener('mouseup', handleMainMouseUp);
361
489
  _boundSvg.removeEventListener('mouseleave', handleMainMouseLeave);
490
+ _boundSvg.removeEventListener('mouseenter', _onMouseEnter);
362
491
  _boundSvg = null;
363
492
  }
364
493
  }
@@ -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,23 +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 = false;
1427
- for (const shape of multiSelection.selectedShapes) {
1428
- if (shape.contains && shape.contains(x, y)) {
1429
- clickedOnSelectedShape = true;
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
- 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);
1435
1440
  return true;
1436
1441
  }
1437
- // Click was in bounds but not on a selected shape — fall through
1438
- // to allow clicking on other shapes or starting a new selection
1442
+ multiSelection.startDrag(e);
1443
+ return true;
1439
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
1440
1448
  }
1441
1449
 
1442
1450
  // Check if clicking on individual shape anchors - let them handle it
@@ -1496,6 +1504,12 @@ function handleMultiSelectionMouseDown(e) {
1496
1504
  return true;
1497
1505
  }
1498
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
+
1499
1513
  // If it's the same shape that's already selected, let individual handlers manage it
1500
1514
  if (currentShape === clickedShape) {
1501
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() {
@@ -339,16 +342,10 @@ class FreehandStroke {
339
342
  // Accumulate offset for transform-based movement (avoids full path rebuild)
340
343
  this._moveOffsetX = (this._moveOffsetX || 0) + dx;
341
344
  this._moveOffsetY = (this._moveOffsetY || 0) + dy;
342
- const rot = this.rotation ? `rotate(${this.rotation} ${this._rotCenterX || 0} ${this._rotCenterY || 0})` : '';
345
+ const centerX = this.boundingBox.x + this.boundingBox.width / 2;
346
+ const centerY = this.boundingBox.y + this.boundingBox.height / 2;
347
+ const rot = this.rotation ? `rotate(${this.rotation} ${centerX} ${centerY})` : '';
343
348
  this.group.setAttribute('transform', `translate(${this._moveOffsetX}, ${this._moveOffsetY}) ${rot}`);
344
-
345
- // Only update frame containment if we're actively dragging the shape itself
346
- // and not being moved by a parent frame
347
- if (isDraggingStroke && !this.isBeingMovedByFrame) {
348
- this.updateFrameContainment();
349
- }
350
-
351
- this.updateAttachedArrows();
352
349
  }
353
350
 
354
351
  // Call after drag ends to bake the offset into actual point coordinates
@@ -364,37 +361,9 @@ class FreehandStroke {
364
361
  }
365
362
 
366
363
  updateAttachedArrows() {
367
- updateArrowsForShape(this);
368
- }
369
-
370
- updateFrameContainment() {
371
- // Don't update if we're being moved by a frame
372
- if (this.isBeingMovedByFrame) return;
373
-
374
- let targetFrame = null;
375
-
376
- // Find which frame this shape is over
377
- shapes.forEach(shape => {
378
- if (shape.shapeName === 'frame' && shape.isShapeInFrame(this)) {
379
- targetFrame = shape;
380
- }
381
- });
382
-
383
- // If we have a parent frame and we're being dragged, temporarily remove clipping
384
- if (this.parentFrame && isDraggingStroke) {
385
- this.parentFrame.temporarilyRemoveFromFrame(this);
364
+ if (typeof window.__updateArrowsForShape === 'function') {
365
+ window.__updateArrowsForShape(this);
386
366
  }
387
-
388
- // Update frame highlighting
389
- if (hoveredFrameStroke && hoveredFrameStroke !== targetFrame) {
390
- hoveredFrameStroke.removeHighlight();
391
- }
392
-
393
- if (targetFrame && targetFrame !== hoveredFrameStroke) {
394
- targetFrame.highlightFrame();
395
- }
396
-
397
- hoveredFrameStroke = targetFrame;
398
367
  }
399
368
 
400
369
  selectStroke() {
@@ -423,11 +392,15 @@ class FreehandStroke {
423
392
  if (this.rotationAnchor && this.rotationAnchor.parentNode === this.group) {
424
393
  this.group.removeChild(this.rotationAnchor);
425
394
  }
395
+ if (this.rotationLine && this.rotationLine.parentNode === this.group) {
396
+ this.group.removeChild(this.rotationLine);
397
+ }
426
398
  if (this.selectionOutline && this.selectionOutline.parentNode === this.group) {
427
399
  this.group.removeChild(this.selectionOutline);
428
400
  }
429
401
  this.anchors = [];
430
402
  this.rotationAnchor = null;
403
+ this.rotationLine = null;
431
404
  this.selectionOutline = null;
432
405
  this.isSelected = false;
433
406
  }
@@ -436,22 +409,26 @@ class FreehandStroke {
436
409
  updateSidebar() {}
437
410
 
438
411
  contains(x, y) {
439
- // Simple bounding box check
440
- const centerX = this.boundingBox.x + this.boundingBox.width / 2;
441
- const centerY = this.boundingBox.y + this.boundingBox.height / 2;
442
-
412
+ // Account for pending move offset (during drag, points aren't updated yet)
413
+ const ox = this._moveOffsetX || 0;
414
+ const oy = this._moveOffsetY || 0;
415
+ const bbX = this.boundingBox.x + ox;
416
+ const bbY = this.boundingBox.y + oy;
417
+ const centerX = bbX + this.boundingBox.width / 2;
418
+ const centerY = bbY + this.boundingBox.height / 2;
419
+
443
420
  // Adjust for rotation
444
421
  const dx = x - centerX;
445
422
  const dy = y - centerY;
446
-
423
+
447
424
  const angleRad = -this.rotation * Math.PI / 180;
448
425
  const rotatedX = dx * Math.cos(angleRad) - dy * Math.sin(angleRad) + centerX;
449
426
  const rotatedY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad) + centerY;
450
-
451
- return rotatedX >= this.boundingBox.x &&
452
- rotatedX <= this.boundingBox.x + this.boundingBox.width &&
453
- rotatedY >= this.boundingBox.y &&
454
- rotatedY <= this.boundingBox.y + this.boundingBox.height;
427
+
428
+ return rotatedX >= bbX &&
429
+ rotatedX <= bbX + this.boundingBox.width &&
430
+ rotatedY >= bbY &&
431
+ rotatedY <= bbY + this.boundingBox.height;
455
432
  }
456
433
 
457
434
  isNearAnchor(x, y) {
@@ -459,10 +436,14 @@ class FreehandStroke {
459
436
  const buffer = 10;
460
437
  const anchorSize = 10 / currentZoom;
461
438
 
439
+ // Account for pending move offset
440
+ const ox = this._moveOffsetX || 0;
441
+ const oy = this._moveOffsetY || 0;
442
+
462
443
  // Transform the input coordinates to account for rotation
463
- const centerX = this.boundingBox.x + this.boundingBox.width / 2;
464
- const centerY = this.boundingBox.y + this.boundingBox.height / 2;
465
-
444
+ const centerX = this.boundingBox.x + ox + this.boundingBox.width / 2;
445
+ const centerY = this.boundingBox.y + oy + this.boundingBox.height / 2;
446
+
466
447
  // Rotate the mouse coordinates to the shape's local coordinate system
467
448
  const angleRad = -this.rotation * Math.PI / 180;
468
449
  const dx = x - centerX;
@@ -471,8 +452,8 @@ class FreehandStroke {
471
452
  const localY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad) + centerY;
472
453
 
473
454
  // Check resize anchors in local space
474
- const expandedX = this.boundingBox.x - this.selectionPadding;
475
- 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;
476
457
  const expandedWidth = this.boundingBox.width + 2 * this.selectionPadding;
477
458
  const expandedHeight = this.boundingBox.height + 2 * this.selectionPadding;
478
459
 
@@ -618,6 +599,7 @@ class FreehandStroke {
618
599
  rotationLine.setAttribute('stroke-dasharray', '2 2');
619
600
  rotationLine.setAttribute('style', 'pointer-events: none;');
620
601
  this.group.appendChild(rotationLine);
602
+ this.rotationLine = rotationLine;
621
603
 
622
604
  // Show sidebar when anchors are added (when shape is selected)
623
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