@elixpo/lixsketch 4.6.2 → 5.4.0

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.2",
3
+ "version": "5.4.0",
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,81 @@ 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
+ // 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);
81
+ }
82
+
83
+ function _onDocumentDragUp(e) {
84
+ _stopAutoScroll();
85
+ document.removeEventListener('mousemove', _onDocumentDragMove);
86
+ document.removeEventListener('mouseup', _onDocumentDragUp);
87
+ _documentDragActive = false;
88
+ // Finalize the operation
89
+ handleMainMouseUp(e);
90
+ }
91
+
17
92
  const handleMainMouseDown = (e) => {
18
93
  // Safety: remove any stray selection rectangle from a previous interrupted drag
19
94
  removeMultiSelectionRect();
@@ -236,9 +311,18 @@ const handleMainMouseMove = (e) => {
236
311
  handleCodeMouseMove(e);
237
312
  }
238
313
  }
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
+ }
239
322
  };
240
323
 
241
324
  const handleMainMouseUp = (e) => {
325
+ _stopAutoScroll();
242
326
  if (isSquareToolActive) {
243
327
  handleMouseUpRect(e);
244
328
  } else if (isArrowToolActive) {
@@ -318,6 +402,21 @@ const handleMainMouseUp = (e) => {
318
402
  };
319
403
 
320
404
  const handleMainMouseLeave = (e) => {
405
+ // If the user is still holding the primary button (dragging outside the canvas),
406
+ // keep auto-scrolling and don't finalize the operation yet.
407
+ if (e.buttons & 1) {
408
+ _lastDragEvent = e;
409
+ _startAutoScroll();
410
+
411
+ // Listen on document to continue receiving move events outside the SVG
412
+ if (!_documentDragActive) {
413
+ _documentDragActive = true;
414
+ document.addEventListener('mousemove', _onDocumentDragMove);
415
+ document.addEventListener('mouseup', _onDocumentDragUp);
416
+ }
417
+ return;
418
+ }
419
+
321
420
  // Stop all active drawing tools when pointer leaves the canvas
322
421
 
323
422
  // Fire mouseUp for whichever tool is active to finalize/cancel the operation
@@ -343,6 +442,15 @@ const handleMainMouseLeave = (e) => {
343
442
 
344
443
  let _boundSvg = null;
345
444
 
445
+ function _onMouseEnter(e) {
446
+ // Cursor re-entered the SVG — stop document-level tracking, SVG handles events again
447
+ if (_documentDragActive) {
448
+ document.removeEventListener('mousemove', _onDocumentDragMove);
449
+ document.removeEventListener('mouseup', _onDocumentDragUp);
450
+ _documentDragActive = false;
451
+ }
452
+ }
453
+
346
454
  function initEventDispatcher(svgEl) {
347
455
  if (_boundSvg) cleanupEventDispatcher();
348
456
  const target = svgEl || svg;
@@ -350,15 +458,23 @@ function initEventDispatcher(svgEl) {
350
458
  target.addEventListener('mousemove', handleMainMouseMove);
351
459
  target.addEventListener('mouseup', handleMainMouseUp);
352
460
  target.addEventListener('mouseleave', handleMainMouseLeave);
461
+ target.addEventListener('mouseenter', _onMouseEnter);
353
462
  _boundSvg = target;
354
463
  }
355
464
 
356
465
  function cleanupEventDispatcher() {
466
+ _stopAutoScroll();
467
+ if (_documentDragActive) {
468
+ document.removeEventListener('mousemove', _onDocumentDragMove);
469
+ document.removeEventListener('mouseup', _onDocumentDragUp);
470
+ _documentDragActive = false;
471
+ }
357
472
  if (_boundSvg) {
358
473
  _boundSvg.removeEventListener('mousedown', handleMainMouseDown);
359
474
  _boundSvg.removeEventListener('mousemove', handleMainMouseMove);
360
475
  _boundSvg.removeEventListener('mouseup', handleMainMouseUp);
361
476
  _boundSvg.removeEventListener('mouseleave', handleMainMouseLeave);
477
+ _boundSvg.removeEventListener('mouseenter', _onMouseEnter);
362
478
  _boundSvg = null;
363
479
  }
364
480
  }
@@ -1423,14 +1423,19 @@ function handleMultiSelectionMouseDown(e) {
1423
1423
  if (multiSelection.isPointInBounds(x, y)) {
1424
1424
  // Only start drag if clicking on a shape that's part of the selection
1425
1425
  // This allows clicking through empty areas of the selection rectangle
1426
- let clickedOnSelectedShape = false;
1426
+ let clickedOnSelectedShape = null;
1427
1427
  for (const shape of multiSelection.selectedShapes) {
1428
1428
  if (shape.contains && shape.contains(x, y)) {
1429
- clickedOnSelectedShape = true;
1429
+ clickedOnSelectedShape = shape;
1430
1430
  break;
1431
1431
  }
1432
1432
  }
1433
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
+ }
1434
1439
  multiSelection.startDrag(e);
1435
1440
  return true;
1436
1441
  }
@@ -339,7 +339,9 @@ class FreehandStroke {
339
339
  // Accumulate offset for transform-based movement (avoids full path rebuild)
340
340
  this._moveOffsetX = (this._moveOffsetX || 0) + dx;
341
341
  this._moveOffsetY = (this._moveOffsetY || 0) + dy;
342
- const rot = this.rotation ? `rotate(${this.rotation} ${this._rotCenterX || 0} ${this._rotCenterY || 0})` : '';
342
+ const centerX = this.boundingBox.x + this.boundingBox.width / 2;
343
+ const centerY = this.boundingBox.y + this.boundingBox.height / 2;
344
+ const rot = this.rotation ? `rotate(${this.rotation} ${centerX} ${centerY})` : '';
343
345
  this.group.setAttribute('transform', `translate(${this._moveOffsetX}, ${this._moveOffsetY}) ${rot}`);
344
346
 
345
347
  // Only update frame containment if we're actively dragging the shape itself
@@ -436,22 +438,26 @@ class FreehandStroke {
436
438
  updateSidebar() {}
437
439
 
438
440
  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
-
441
+ // Account for pending move offset (during drag, points aren't updated yet)
442
+ const ox = this._moveOffsetX || 0;
443
+ const oy = this._moveOffsetY || 0;
444
+ const bbX = this.boundingBox.x + ox;
445
+ const bbY = this.boundingBox.y + oy;
446
+ const centerX = bbX + this.boundingBox.width / 2;
447
+ const centerY = bbY + this.boundingBox.height / 2;
448
+
443
449
  // Adjust for rotation
444
450
  const dx = x - centerX;
445
451
  const dy = y - centerY;
446
-
452
+
447
453
  const angleRad = -this.rotation * Math.PI / 180;
448
454
  const rotatedX = dx * Math.cos(angleRad) - dy * Math.sin(angleRad) + centerX;
449
455
  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;
456
+
457
+ return rotatedX >= bbX &&
458
+ rotatedX <= bbX + this.boundingBox.width &&
459
+ rotatedY >= bbY &&
460
+ rotatedY <= bbY + this.boundingBox.height;
455
461
  }
456
462
 
457
463
  isNearAnchor(x, y) {