@elixpo/lixsketch 4.6.3 → 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 +1 -1
- package/src/core/EventDispatcher.js +116 -0
- package/src/core/Selection.js +7 -2
- package/src/shapes/FreehandStroke.js +17 -11
package/package.json
CHANGED
|
@@ -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
|
}
|
package/src/core/Selection.js
CHANGED
|
@@ -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 =
|
|
1426
|
+
let clickedOnSelectedShape = null;
|
|
1427
1427
|
for (const shape of multiSelection.selectedShapes) {
|
|
1428
1428
|
if (shape.contains && shape.contains(x, y)) {
|
|
1429
|
-
clickedOnSelectedShape =
|
|
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
|
|
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
|
-
//
|
|
440
|
-
const
|
|
441
|
-
const
|
|
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 >=
|
|
452
|
-
rotatedX <=
|
|
453
|
-
rotatedY >=
|
|
454
|
-
rotatedY <=
|
|
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) {
|