@oh-just-another/state 0.59.0 → 0.60.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/CHANGELOG.md +91 -0
- package/README.md +2 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/actionArrange.js +4 -4
- package/dist/actions/actionArrange.js.map +1 -1
- package/dist/actions/actionClipboard.d.ts.map +1 -1
- package/dist/actions/actionClipboard.js +5 -0
- package/dist/actions/actionClipboard.js.map +1 -1
- package/dist/actions/actionKeyboard.d.ts.map +1 -1
- package/dist/actions/actionKeyboard.js +77 -9
- package/dist/actions/actionKeyboard.js.map +1 -1
- package/dist/actions/actionMode.d.ts +4 -0
- package/dist/actions/actionMode.d.ts.map +1 -1
- package/dist/actions/actionMode.js +54 -0
- package/dist/actions/actionMode.js.map +1 -1
- package/dist/actions/actionSelection.d.ts.map +1 -1
- package/dist/actions/actionSelection.js +1 -0
- package/dist/actions/actionSelection.js.map +1 -1
- package/dist/actions/actionView.d.ts +6 -0
- package/dist/actions/actionView.d.ts.map +1 -1
- package/dist/actions/actionView.js +19 -1
- package/dist/actions/actionView.js.map +1 -1
- package/dist/actions/actionZoom.d.ts.map +1 -1
- package/dist/actions/actionZoom.js +5 -0
- package/dist/actions/actionZoom.js.map +1 -1
- package/dist/actions/index.d.ts +3 -3
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +3 -3
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/registry.d.ts.map +1 -1
- package/dist/actions/registry.js +13 -0
- package/dist/actions/registry.js.map +1 -1
- package/dist/actions/types.d.ts +9 -0
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js.map +1 -1
- package/dist/clipboard.d.ts +0 -14
- package/dist/clipboard.d.ts.map +1 -1
- package/dist/clipboard.js +1 -1
- package/dist/clipboard.js.map +1 -1
- package/dist/constants.d.ts +160 -18
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +162 -2
- package/dist/constants.js.map +1 -1
- package/dist/dom-events.d.ts +1 -2
- package/dist/dom-events.d.ts.map +1 -1
- package/dist/dom-events.js.map +1 -1
- package/dist/editor/animation-scene.d.ts +15 -0
- package/dist/editor/animation-scene.d.ts.map +1 -1
- package/dist/editor/animation-scene.js +123 -0
- package/dist/editor/animation-scene.js.map +1 -1
- package/dist/editor/applies/create.d.ts +0 -6
- package/dist/editor/applies/create.d.ts.map +1 -1
- package/dist/editor/applies/create.js +1 -1
- package/dist/editor/applies/create.js.map +1 -1
- package/dist/editor/applies/link-move.d.ts +0 -8
- package/dist/editor/applies/link-move.d.ts.map +1 -1
- package/dist/editor/applies/link-move.js +1 -1
- package/dist/editor/applies/link-move.js.map +1 -1
- package/dist/editor/interaction-state.d.ts +184 -0
- package/dist/editor/interaction-state.d.ts.map +1 -0
- package/dist/editor/interaction-state.js +139 -0
- package/dist/editor/interaction-state.js.map +1 -0
- package/dist/editor/link-handle-drag.d.ts +152 -0
- package/dist/editor/link-handle-drag.d.ts.map +1 -0
- package/dist/editor/link-handle-drag.js +300 -0
- package/dist/editor/link-handle-drag.js.map +1 -0
- package/dist/editor/pointer-binding.d.ts +9 -11
- package/dist/editor/pointer-binding.d.ts.map +1 -1
- package/dist/editor/pointer-binding.js +1185 -901
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/brush.d.ts +31 -12
- package/dist/editor/public/brush.d.ts.map +1 -1
- package/dist/editor/public/brush.js +66 -17
- package/dist/editor/public/brush.js.map +1 -1
- package/dist/editor/public/cursor.d.ts +1 -15
- package/dist/editor/public/cursor.d.ts.map +1 -1
- package/dist/editor/public/cursor.js +14 -3
- package/dist/editor/public/cursor.js.map +1 -1
- package/dist/editor/public/eraser.d.ts +55 -0
- package/dist/editor/public/eraser.d.ts.map +1 -0
- package/dist/editor/public/eraser.js +65 -0
- package/dist/editor/public/eraser.js.map +1 -0
- package/dist/editor/public/laser.d.ts +38 -0
- package/dist/editor/public/laser.d.ts.map +1 -0
- package/dist/editor/public/laser.js +42 -0
- package/dist/editor/public/laser.js.map +1 -0
- package/dist/editor/public/placement.d.ts.map +1 -1
- package/dist/editor/public/placement.js +9 -1
- package/dist/editor/public/placement.js.map +1 -1
- package/dist/editor/public/selection-ops.d.ts +12 -0
- package/dist/editor/public/selection-ops.d.ts.map +1 -1
- package/dist/editor/public/selection-ops.js +19 -1
- package/dist/editor/public/selection-ops.js.map +1 -1
- package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
- package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
- package/dist/editor/public/stroke-eraser-coverage.js +168 -0
- package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
- package/dist/editor/public/stroke-eraser.d.ts +50 -0
- package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
- package/dist/editor/public/stroke-eraser.js +176 -0
- package/dist/editor/public/stroke-eraser.js.map +1 -0
- package/dist/editor/public/stroke-smoothing.d.ts +21 -0
- package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
- package/dist/editor/public/stroke-smoothing.js +37 -0
- package/dist/editor/public/stroke-smoothing.js.map +1 -0
- package/dist/editor/public/tool-ops.d.ts +130 -0
- package/dist/editor/public/tool-ops.d.ts.map +1 -0
- package/dist/editor/public/tool-ops.js +392 -0
- package/dist/editor/public/tool-ops.js.map +1 -0
- package/dist/editor/public/z-order.d.ts +1 -12
- package/dist/editor/public/z-order.d.ts.map +1 -1
- package/dist/editor/public/z-order.js +1 -1
- package/dist/editor/public/z-order.js.map +1 -1
- package/dist/editor/public/zoom-pan.d.ts +9 -0
- package/dist/editor/public/zoom-pan.d.ts.map +1 -1
- package/dist/editor/public/zoom-pan.js +31 -0
- package/dist/editor/public/zoom-pan.js.map +1 -1
- package/dist/editor/render-orchestrator.d.ts +177 -5
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +517 -351
- package/dist/editor/render-orchestrator.js.map +1 -1
- package/dist/editor/text-edit.d.ts +136 -0
- package/dist/editor/text-edit.d.ts.map +1 -0
- package/dist/editor/text-edit.js +328 -0
- package/dist/editor/text-edit.js.map +1 -0
- package/dist/editor.d.ts +504 -239
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1557 -706
- package/dist/editor.js.map +1 -1
- package/dist/handle.d.ts +0 -2
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +0 -6
- package/dist/handle.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/modes.d.ts +15 -1
- package/dist/modes.d.ts.map +1 -1
- package/dist/modes.js.map +1 -1
- package/dist/overlay.d.ts +91 -2
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +450 -93
- package/dist/overlay.js.map +1 -1
- package/dist/search.d.ts +37 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +45 -0
- package/dist/search.js.map +1 -0
- package/package.json +4 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findContainerAt, getAnchorWorld, getDropZoneWorld, getElement, getElementWorldBounds, getLink, getLinkPath, getLinkWaypointMidpoints, isFrame, isGroup, isImage, isText, updateAnnotation, } from "@oh-just-another/scene";
|
|
2
|
-
import { boundsFromPoints, interpretPressEnd, DRAG_THRESHOLD } from "../machine.js";
|
|
2
|
+
import { boundsFromPoints, interpretPressEnd, DRAG_THRESHOLD, } from "../machine.js";
|
|
3
3
|
import { fromPointerEvent, isEditableTarget } from "../dom-events.js";
|
|
4
4
|
import * as Selection from "../selection.js";
|
|
5
5
|
import * as LinkSelection from "../link-selection.js";
|
|
@@ -8,6 +8,8 @@ import { anchorOverlayPoints } from "./anchor-points.js";
|
|
|
8
8
|
import { snapshotMovingLinks } from "./applies/link-move.js";
|
|
9
9
|
import { ANCHOR_DOT_ACTIVE_RADIUS, LINK_ENDPOINT_HANDLE_RADIUS, LINK_START_ANCHOR_OUTSET, LONG_PRESS_MAX_MOVEMENT_PX, WHEEL_PAN_FACTOR, WHEEL_ZOOM_MAX_STEP, WHEEL_ZOOM_SPEED, } from "../constants.js";
|
|
10
10
|
import { vec2 } from "@oh-just-another/math";
|
|
11
|
+
import { clampZoom } from "./public/zoom-pan.js";
|
|
12
|
+
import { req } from "../util.js";
|
|
11
13
|
/** Inclusive integer range `[a..b]`; empty when `b < a`. */
|
|
12
14
|
const range = (a, b) => {
|
|
13
15
|
const out = [];
|
|
@@ -15,426 +17,512 @@ const range = (a, b) => {
|
|
|
15
17
|
out.push(i);
|
|
16
18
|
return out;
|
|
17
19
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
/** True when `p` lies within `√r2` of `center` (squared-distance compare). */
|
|
21
|
+
const withinRadiusSq = (p, center, r2) => {
|
|
22
|
+
const dx = p.x - center.x;
|
|
23
|
+
const dy = p.y - center.y;
|
|
24
|
+
return dx * dx + dy * dy <= r2;
|
|
25
|
+
};
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// pointer-DOWN handlers
|
|
28
|
+
//
|
|
29
|
+
// Each `handleDown*` returns `true` when it consumed the press (the caller
|
|
30
|
+
// short-circuits), mirroring the early `return`s of the original monolith.
|
|
31
|
+
// The `snapshot*` / `apply*` helpers are the non-short-circuiting tail steps.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
20
33
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Returns
|
|
27
|
-
* installed.
|
|
28
|
-
*
|
|
29
|
-
* Typed against the full `Editor` class (type-only import — erased at
|
|
30
|
-
* runtime, so no import cycle and dependency-cruiser ignores it). This
|
|
31
|
-
* handler reaches deep into Editor's surface, so the members it touches
|
|
32
|
-
* are declared `public` (with the `_`-prefix convention marking them
|
|
33
|
-
* internal). editor.ts is the only call site.
|
|
34
|
+
* Pan-gesture triggers — checked before the normal flow so a right-click or
|
|
35
|
+
* Space/hand + left-click never starts a select/draw gesture. Triggers:
|
|
36
|
+
* • Right / middle mouse button.
|
|
37
|
+
* • Left button + Space held.
|
|
38
|
+
* • Left button in hand mode.
|
|
39
|
+
* Returns true when a pan began (caller short-circuits).
|
|
34
40
|
*/
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// the
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
41
|
+
const handleDownPanTrigger = (editor, ev, data) => {
|
|
42
|
+
const isRightClick = ev.button === 2 || ev.button === 1;
|
|
43
|
+
const isSpaceLeftDrag = ev.button === 0 && editor.spaceHeld;
|
|
44
|
+
const isHandModeLeftDrag = ev.button === 0 && editor.mode === "hand";
|
|
45
|
+
if (isRightClick || isSpaceLeftDrag || isHandModeLeftDrag) {
|
|
46
|
+
// Suppress the next native contextmenu — we'll either pan (drag) or fire
|
|
47
|
+
// the long-press callback at pointerup (click-style right-click).
|
|
48
|
+
if (isRightClick)
|
|
49
|
+
editor.suppressNextContextMenu = true;
|
|
50
|
+
editor.beginPanGesture(ev.pointerId, ev.button, data.point);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Multi-pointer tracking. Records the pointer, then on the *second* concurrent
|
|
57
|
+
* contact cancels the single-pointer gesture and enters pinch; 3+ contacts are
|
|
58
|
+
* ignored. Returns true when the press was absorbed by pinch handling.
|
|
59
|
+
*/
|
|
60
|
+
const handleDownMultiPointer = (editor, ev, data) => {
|
|
61
|
+
editor.activePointers.set(ev.pointerId, data.point);
|
|
62
|
+
if (editor.activePointers.size === 2) {
|
|
63
|
+
// First touch already kicked off a POINTER_DOWN — undo it so the shape
|
|
64
|
+
// under finger #1 doesn't get dragged when finger #2 lands.
|
|
65
|
+
editor.actor.send({ type: "POINTER_CANCEL" });
|
|
66
|
+
editor.cancelGesture();
|
|
67
|
+
editor.cancelLongPress();
|
|
68
|
+
// A one-finger pan may already be in flight — drop it so it doesn't fight
|
|
69
|
+
// the pinch. No menu fires (touch pan is button 0), so clear directly.
|
|
70
|
+
editor.panGesture = null;
|
|
71
|
+
editor.touchPanCandidate = null;
|
|
72
|
+
editor.beginPinch();
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (editor.activePointers.size > 2) {
|
|
76
|
+
// 3-finger and more: stay in pinch mode but ignore additional contacts.
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Active in-canvas text edit owns the pointer. A press inside the edited shape
|
|
83
|
+
* repositions the caret (consumed); a press outside commits the edit and falls
|
|
84
|
+
* through (returns false) so the same click does its normal thing.
|
|
85
|
+
*/
|
|
86
|
+
const handleDownEditingText = (editor, worldPoint) => {
|
|
87
|
+
if (editor.readOnly)
|
|
88
|
+
return false;
|
|
89
|
+
if (editor.editingTextElement === null)
|
|
90
|
+
return false;
|
|
91
|
+
if (editor.editedElementContainsPoint(worldPoint)) {
|
|
92
|
+
editor.cancelLongPress();
|
|
93
|
+
editor.setTextCaretFromPoint(worldPoint);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
editor.commitTextEdit();
|
|
97
|
+
// fall through to normal press handling for this click
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
/** Crop-drag move — resize the window (handle) or pan the source (body). */
|
|
101
|
+
const handleMoveCrop = (editor, worldPoint) => {
|
|
102
|
+
if (editor.mode !== "crop" || editor.cropSession?.drag == null)
|
|
103
|
+
return false;
|
|
104
|
+
editor.updateImageCropDrag(worldPoint);
|
|
105
|
+
return true;
|
|
106
|
+
};
|
|
107
|
+
/** Crop-drag release — finish the drag (keeps the pending crop / box). */
|
|
108
|
+
const handleUpCrop = (editor) => {
|
|
109
|
+
if (editor.mode !== "crop" || editor.cropSession?.drag == null)
|
|
110
|
+
return false;
|
|
111
|
+
editor.endImageCropDrag();
|
|
112
|
+
return true;
|
|
113
|
+
};
|
|
114
|
+
/** Brush mode owns the gesture end-to-end — start a stroke, skip machine. */
|
|
115
|
+
const handleDownBrush = (editor, worldPoint, pressure) => {
|
|
116
|
+
if (editor.readOnly)
|
|
117
|
+
return false;
|
|
118
|
+
if (editor.mode !== "brush")
|
|
119
|
+
return false;
|
|
120
|
+
editor.beginBrushStroke(worldPoint, pressure);
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Eraser mode — begin sweeping shapes under the press. Owns the gesture.
|
|
125
|
+
* `strokeErase` (Shift held) cuts brush strokes into fragments instead of
|
|
126
|
+
* deleting them whole.
|
|
127
|
+
*/
|
|
128
|
+
const handleDownErase = (editor, worldPoint, restore, strokeErase) => {
|
|
129
|
+
if (editor.readOnly)
|
|
130
|
+
return false;
|
|
131
|
+
if (editor.mode !== "erase")
|
|
132
|
+
return false;
|
|
133
|
+
editor.cancelLongPress();
|
|
134
|
+
editor.beginEraseStroke(worldPoint, restore, strokeErase);
|
|
135
|
+
return true;
|
|
136
|
+
};
|
|
137
|
+
/** Laser mode — begin an ephemeral trail. Never touches the scene. */
|
|
138
|
+
const handleDownLaser = (editor, worldPoint) => {
|
|
139
|
+
if (editor.mode !== "laser")
|
|
140
|
+
return false;
|
|
141
|
+
editor.cancelLongPress();
|
|
142
|
+
editor.beginLaserStroke(worldPoint);
|
|
143
|
+
return true;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Eyedropper — a click samples the colour of the shape under the cursor. Fires
|
|
147
|
+
* when the colour-picker pipette is armed ({@link Editor.isEyedropperArmed}, mode
|
|
148
|
+
* unchanged) or in the legacy `eyedropper` tool mode. Owns the gesture end-to-end.
|
|
149
|
+
*/
|
|
150
|
+
const handleDownEyedropper = (editor, worldPoint) => {
|
|
151
|
+
if (editor.mode !== "eyedropper" && !editor.isEyedropperArmed)
|
|
152
|
+
return false;
|
|
153
|
+
editor.cancelLongPress();
|
|
154
|
+
editor.applyEyedropperAt(worldPoint);
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Crop mode — a press hit-tests the crop chrome: a handle starts a window
|
|
159
|
+
* resize, the image body starts a source pan. A press OUTSIDE the image commits
|
|
160
|
+
* the pending crop and falls through so the click does its normal thing.
|
|
161
|
+
* Enter/Escape (handled by the crop actions) also commit / cancel. Owns the
|
|
162
|
+
* gesture only when it grabbed a handle / the body.
|
|
163
|
+
*/
|
|
164
|
+
const handleDownCrop = (editor, worldPoint) => {
|
|
165
|
+
if (editor.mode !== "crop" || editor.cropSession === null)
|
|
166
|
+
return false;
|
|
167
|
+
const target = editor.cropHandleAtWorld(worldPoint);
|
|
168
|
+
if (target === null) {
|
|
169
|
+
// Clicked away from the image — apply the crop and let the press proceed.
|
|
170
|
+
editor.commitImageCrop();
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
editor.cancelLongPress();
|
|
174
|
+
if (target === "body")
|
|
175
|
+
editor.beginImageCropBody(worldPoint);
|
|
176
|
+
else
|
|
177
|
+
editor.beginImageCropHandle(target, worldPoint);
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Double-click on an image (in select mode) enters crop mode. Detected via the
|
|
182
|
+
* native click-count (`detail`), matching the browser's dblclick timing.
|
|
183
|
+
* `detail` may be absent (synthetic events / pointer backends that don't set
|
|
184
|
+
* it); treat a missing count as a single click so a plain tap never enters
|
|
185
|
+
* crop (which would otherwise swallow the press and break tap gestures).
|
|
186
|
+
*/
|
|
187
|
+
const handleDownCropEnter = (editor, worldPoint, detail) => {
|
|
188
|
+
if (editor.readOnly || editor.mode !== "select" || (detail || 0) < 2)
|
|
189
|
+
return false;
|
|
190
|
+
const hit = editor.hitTest(worldPoint);
|
|
191
|
+
if (hit.kind !== "element")
|
|
192
|
+
return false;
|
|
193
|
+
const el = getElement(editor._scene, hit.id);
|
|
194
|
+
if (el === undefined || !isImage(el))
|
|
195
|
+
return false;
|
|
196
|
+
editor.beginImageCrop(hit.id);
|
|
197
|
+
return true;
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Text tool — a click places a new empty text shape (or edits an existing one
|
|
201
|
+
* under the press) and opens the inline editor straight away.
|
|
202
|
+
*/
|
|
203
|
+
const handleDownDrawText = (editor, worldPoint) => {
|
|
204
|
+
if (editor.readOnly)
|
|
205
|
+
return false;
|
|
206
|
+
if (editor.mode !== "draw-text")
|
|
207
|
+
return false;
|
|
208
|
+
editor.cancelLongPress();
|
|
209
|
+
const hit = editor.hitTest(worldPoint);
|
|
210
|
+
const existing = hit.kind === "element" ? getElement(editor._scene, hit.id) : undefined;
|
|
211
|
+
if (existing !== undefined && isText(existing)) {
|
|
212
|
+
editor._selection = Selection.single(existing.id);
|
|
213
|
+
editor.beginTextEdit(existing.id);
|
|
214
|
+
editor.notify();
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
editor.createTextAt(worldPoint);
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Annotation pin drag — when the press lands on a pin, take over the gesture
|
|
223
|
+
* entirely (skip machine, skip interactive testers).
|
|
224
|
+
*/
|
|
225
|
+
const handleDownAnnotation = (editor, worldPoint) => {
|
|
226
|
+
if (editor.readOnly)
|
|
227
|
+
return false;
|
|
228
|
+
const annHit = editor.hitAnnotation(worldPoint);
|
|
229
|
+
if (!annHit)
|
|
230
|
+
return false;
|
|
231
|
+
const ann = editor._scene.annotations.get(annHit);
|
|
232
|
+
if (!ann)
|
|
233
|
+
return false;
|
|
234
|
+
editor.annotationDrag = {
|
|
235
|
+
id: annHit,
|
|
236
|
+
originPosition: { ...ann.position },
|
|
237
|
+
originWorldPoint: worldPoint,
|
|
238
|
+
moved: false,
|
|
239
|
+
};
|
|
240
|
+
editor.setSelectedAnnotation(annHit);
|
|
241
|
+
return true;
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Interactive sub-element check: when the press lands on a shape whose type has
|
|
245
|
+
* a registered hit-tester and the tester finds an interactive node, fire its
|
|
246
|
+
* emit and skip the normal press flow.
|
|
247
|
+
*/
|
|
248
|
+
const handleDownInteractiveHit = (editor, worldPoint) => {
|
|
249
|
+
const topElement = editor.acceleratedElementAt(worldPoint);
|
|
250
|
+
if (!topElement)
|
|
251
|
+
return false;
|
|
252
|
+
const tester = getInteractiveHitTester(topElement.type);
|
|
253
|
+
if (!tester)
|
|
254
|
+
return false;
|
|
255
|
+
const local = {
|
|
256
|
+
x: worldPoint.x - topElement.position.x,
|
|
257
|
+
y: worldPoint.y - topElement.position.y,
|
|
258
|
+
};
|
|
259
|
+
const emit = tester(topElement, local);
|
|
260
|
+
if (emit) {
|
|
261
|
+
editor.applyEmit(emit);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* Elbow segment drag: a press on an interior segment's midpoint handle moves
|
|
268
|
+
* the whole segment perpendicular. Checked before the hit-test so it isn't read
|
|
269
|
+
* as deselect.
|
|
270
|
+
*/
|
|
271
|
+
const handleDownSegmentDrag = (editor, worldPoint) => {
|
|
272
|
+
if (editor.readOnly)
|
|
273
|
+
return false;
|
|
274
|
+
const segDragLink = editor.selectedLink;
|
|
275
|
+
if (!(editor.mode === "select" && segDragLink))
|
|
276
|
+
return false;
|
|
277
|
+
const edge = getLink(editor._scene, segDragLink);
|
|
278
|
+
if (!(edge && (edge.routing ?? "straight") === "orthogonal"))
|
|
279
|
+
return false;
|
|
280
|
+
const path = getLinkPath(editor._scene, edge);
|
|
281
|
+
if (!(path && path.length >= 2))
|
|
282
|
+
return false;
|
|
283
|
+
const zoom = editor._scene.viewport.zoom || 1;
|
|
284
|
+
const r = LINK_ENDPOINT_HANDLE_RADIUS / zoom;
|
|
285
|
+
const r2 = r * r;
|
|
286
|
+
// Draggable segments: the single segment of a straight elbow (grab to bend it
|
|
287
|
+
// → insert), or interior segments k in 1..len-3 of a routed elbow (terminal
|
|
288
|
+
// stubs touch from/to and aren't slid).
|
|
289
|
+
const segs = path.length === 2 ? [0] : range(1, path.length - 3);
|
|
290
|
+
for (const k of segs) {
|
|
291
|
+
const a = req(path[k]);
|
|
292
|
+
const b = req(path[k + 1]);
|
|
293
|
+
const mx = (a.x + b.x) / 2;
|
|
294
|
+
const my = (a.y + b.y) / 2;
|
|
295
|
+
const dx = mx - worldPoint.x;
|
|
296
|
+
const dy = my - worldPoint.y;
|
|
297
|
+
if (dx * dx + dy * dy <= r2) {
|
|
298
|
+
const axis = Math.abs(a.y - b.y) < 1e-6 ? "h" : "v";
|
|
299
|
+
const at = axis === "h" ? (a.x + b.x) / 2 : (a.y + b.y) / 2;
|
|
300
|
+
const pos = axis === "h" ? a.y : a.x; // pinned perpendicular coord
|
|
301
|
+
// Double-click a segment handle → drop its pin (back to auto route).
|
|
302
|
+
if (editor.isHandleDoubleClick(worldPoint)) {
|
|
303
|
+
editor.resetSegmentPin(segDragLink, axis, pos, at);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
editor.beginSegmentDrag(segDragLink, axis, at);
|
|
105
307
|
}
|
|
106
|
-
editor.commitTextEdit();
|
|
107
|
-
// fall through to normal press handling for this click
|
|
108
|
-
}
|
|
109
|
-
// Brush mode owns the gesture end-to-end — no machine, no
|
|
110
|
-
// interactive testers, no auto-select. Start a stroke at the
|
|
111
|
-
// press point with the device's pressure; onMove extends; onUp
|
|
112
|
-
// commits as a single BrushElement patch.
|
|
113
|
-
if (editor.mode === "brush") {
|
|
114
|
-
editor.beginBrushStroke(worldPoint, ev.pressure);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
// Text tool — a click places a new empty text shape (or, when it
|
|
118
|
-
// lands on an existing text shape, edits that one) and opens the
|
|
119
|
-
// inline editor straight away. Click-based, no rubber-band; we
|
|
120
|
-
// intercept before the machine flow like brush. Cancel the
|
|
121
|
-
// long-press so its context menu can't pop over the editor.
|
|
122
|
-
if (editor.mode === "draw-text") {
|
|
123
308
|
editor.cancelLongPress();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Bend-point (waypoint) drag on the selected link. A press on an existing
|
|
316
|
+
* waypoint handle moves it; a press on a segment-midpoint handle inserts a new
|
|
317
|
+
* waypoint there (on first move). Checked before the normal hit-test.
|
|
318
|
+
*/
|
|
319
|
+
const handleDownWaypointDrag = (editor, worldPoint) => {
|
|
320
|
+
if (editor.readOnly)
|
|
321
|
+
return false;
|
|
322
|
+
const wpDragLink = editor.selectedLink;
|
|
323
|
+
if (!(editor.mode === "select" && wpDragLink))
|
|
324
|
+
return false;
|
|
325
|
+
const edge = getLink(editor._scene, wpDragLink);
|
|
326
|
+
// Elbow links use segment-drag (separate mechanic), not free waypoints.
|
|
327
|
+
const path = edge && (edge.routing ?? "straight") !== "orthogonal" ? getLinkPath(editor._scene, edge) : null;
|
|
328
|
+
if (!(edge && path && path.length >= 2))
|
|
329
|
+
return false;
|
|
330
|
+
const zoom = editor._scene.viewport.zoom || 1;
|
|
331
|
+
const r = LINK_ENDPOINT_HANDLE_RADIUS / zoom;
|
|
332
|
+
const r2 = r * r;
|
|
333
|
+
const waypoints = [...(edge.waypoints ?? [])];
|
|
334
|
+
// Existing waypoints take priority over the midpoint "add" handles.
|
|
335
|
+
let grabbed = false;
|
|
336
|
+
for (let i = 0; i < waypoints.length; i++) {
|
|
337
|
+
if (withinRadiusSq(req(waypoints[i]), worldPoint, r2)) {
|
|
338
|
+
// Double-click a waypoint handle → delete the bend point.
|
|
339
|
+
if (editor.isHandleDoubleClick(worldPoint)) {
|
|
340
|
+
editor.deleteWaypoint(wpDragLink, i);
|
|
130
341
|
}
|
|
131
342
|
else {
|
|
132
|
-
editor.
|
|
343
|
+
editor.beginWaypointDrag(wpDragLink, i, false);
|
|
133
344
|
}
|
|
134
|
-
|
|
345
|
+
grabbed = true;
|
|
346
|
+
break;
|
|
135
347
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
editor.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
originWorldPoint: worldPoint,
|
|
147
|
-
moved: false,
|
|
148
|
-
};
|
|
149
|
-
editor.setSelectedAnnotation(annHit);
|
|
150
|
-
return;
|
|
348
|
+
}
|
|
349
|
+
if (!grabbed) {
|
|
350
|
+
// "Add waypoint" handles sit on the drawn arc (bezier) / chord (straight) —
|
|
351
|
+
// same geometry the overlay renders, so the click target matches.
|
|
352
|
+
const mids = getLinkWaypointMidpoints(editor._scene, edge) ?? [];
|
|
353
|
+
for (let i = 0; i < mids.length; i++) {
|
|
354
|
+
if (withinRadiusSq(req(mids[i]), worldPoint, r2)) {
|
|
355
|
+
editor.beginWaypointDrag(wpDragLink, i, true);
|
|
356
|
+
grabbed = true;
|
|
357
|
+
break;
|
|
151
358
|
}
|
|
152
359
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
360
|
+
}
|
|
361
|
+
if (grabbed) {
|
|
362
|
+
editor.cancelLongPress();
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Begin a link FROM a start dot of `shapeId` (connection dots only on the
|
|
369
|
+
* selected shape). The dots sit OUTSIDE the shape; they're grabbable exactly
|
|
370
|
+
* where the overlay draws them (shared `anchorOverlayPoints`). Returns true when
|
|
371
|
+
* a link-from-anchor drag started.
|
|
372
|
+
*/
|
|
373
|
+
const tryAnchorDrag = (editor, shapeId, worldPoint) => {
|
|
374
|
+
const shape = getElement(editor._scene, shapeId);
|
|
375
|
+
if (!shape)
|
|
376
|
+
return false;
|
|
377
|
+
const zoom = editor._scene.viewport.zoom || 1;
|
|
378
|
+
const { names, worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
379
|
+
const grab = (ANCHOR_DOT_ACTIVE_RADIUS + editor.anchorStartHitSlop) / zoom;
|
|
380
|
+
let bestName = null;
|
|
381
|
+
let bestD2 = grab * grab;
|
|
382
|
+
for (let i = 0; i < worldPoints.length; i++) {
|
|
383
|
+
const p = req(worldPoints[i]);
|
|
384
|
+
const dx = p.x - worldPoint.x;
|
|
385
|
+
const dy = p.y - worldPoint.y;
|
|
386
|
+
const d2 = dx * dx + dy * dy;
|
|
387
|
+
if (d2 <= bestD2) {
|
|
388
|
+
bestD2 = d2;
|
|
389
|
+
bestName = req(names[i]);
|
|
172
390
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
391
|
+
}
|
|
392
|
+
if (bestName === null)
|
|
393
|
+
return false;
|
|
394
|
+
editor.cancelLongPress();
|
|
395
|
+
editor.linkDragFromAnchor = {
|
|
396
|
+
fromElement: shapeId,
|
|
397
|
+
fromWorld: getAnchorWorld(shape, { kind: "named", name: bestName }),
|
|
398
|
+
anchorName: bestName,
|
|
399
|
+
origin: worldPoint,
|
|
400
|
+
moved: false,
|
|
401
|
+
};
|
|
402
|
+
return true;
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Link-start anchor drag: a press on one of the single selected element's
|
|
406
|
+
* link-start dots begins an edge FROM that anchor. Checked before the normal
|
|
407
|
+
* hit-test / auto-select. Modifier-clicks fall through to normal select; the
|
|
408
|
+
* rotate grip (floating above the top anchor) must win, so it's excluded.
|
|
409
|
+
*/
|
|
410
|
+
const handleDownAnchorStart = (editor, data, worldPoint, target) => {
|
|
411
|
+
if (editor.readOnly)
|
|
412
|
+
return false;
|
|
413
|
+
if (!(editor.mode === "select" &&
|
|
414
|
+
!data.modifiers.shift &&
|
|
415
|
+
!data.modifiers.meta &&
|
|
416
|
+
!data.modifiers.ctrl &&
|
|
417
|
+
target.kind !== "rotate-handle")) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
const selId = editor._selection.size === 1 ? [...editor._selection][0] : null;
|
|
421
|
+
if (selId && tryAnchorDrag(editor, selId, worldPoint))
|
|
422
|
+
return true;
|
|
423
|
+
return false;
|
|
424
|
+
};
|
|
425
|
+
/**
|
|
426
|
+
* Auto-select on press for shapes/edges the user is about to act on. Pressing
|
|
427
|
+
* an unselected element promotes it BEFORE the drag; Shift/Cmd extends instead
|
|
428
|
+
* of replacing. Cmd/Ctrl-click on a linked shape is a link-open gesture (handled
|
|
429
|
+
* on up), NOT additive select — so selection is left untouched there.
|
|
430
|
+
*/
|
|
431
|
+
const applyAutoSelect = (editor, data, target) => {
|
|
432
|
+
const linkModifier = data.modifiers.meta || data.modifiers.ctrl;
|
|
433
|
+
const isLinkOpen = linkModifier && target.kind === "element" && editor.elementLink(target.id) !== null;
|
|
434
|
+
if (!isLinkOpen && target.kind === "element" && !editor._selection.has(target.id)) {
|
|
435
|
+
const additive = data.modifiers.shift || data.modifiers.meta || data.modifiers.ctrl;
|
|
436
|
+
editor._selection = additive
|
|
437
|
+
? Selection.add(editor._selection, target.id)
|
|
438
|
+
: Selection.single(target.id);
|
|
439
|
+
// Remember an additive promotion so a tap's up-handler doesn't
|
|
440
|
+
// SELECT_TOGGLE it back off.
|
|
441
|
+
if (additive)
|
|
442
|
+
editor.additivePressAdded = target.id;
|
|
443
|
+
// Plain press replaces the whole selection, so drop any selected links; an
|
|
444
|
+
// additive press keeps them.
|
|
445
|
+
if (!additive && editor._selectedLinks.size > 0) {
|
|
446
|
+
editor._selectedLinks = LinkSelection.EMPTY;
|
|
213
447
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
editor.cancelLongPress();
|
|
265
|
-
return;
|
|
448
|
+
editor.notify();
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
/**
|
|
452
|
+
* ⌥-drag duplicate: holding Alt and pressing a selected element clones the
|
|
453
|
+
* whole selection IN PLACE; the drag then moves the clones, leaving originals.
|
|
454
|
+
* Returns the (possibly re-pointed) press target so the snapshot drags the clone.
|
|
455
|
+
*/
|
|
456
|
+
const applyAltDragDuplicate = (editor, data, target) => {
|
|
457
|
+
if (editor.readOnly)
|
|
458
|
+
return target;
|
|
459
|
+
if (data.modifiers.alt &&
|
|
460
|
+
editor.mode === "select" &&
|
|
461
|
+
target.kind === "element" &&
|
|
462
|
+
editor._selection.has(target.id)) {
|
|
463
|
+
const clone = editor.duplicateSelectedInPlace(target.id);
|
|
464
|
+
if (clone !== null)
|
|
465
|
+
return { kind: "element", id: clone, bounds: target.bounds };
|
|
466
|
+
}
|
|
467
|
+
return target;
|
|
468
|
+
};
|
|
469
|
+
/** Recursively add a group and all its descendants to `ids`. */
|
|
470
|
+
const addGroupDescendants = (editor, parentId, ids) => {
|
|
471
|
+
if (ids.has(parentId))
|
|
472
|
+
return;
|
|
473
|
+
ids.add(parentId);
|
|
474
|
+
for (const child of editor._scene.elements.values()) {
|
|
475
|
+
if (child.parentId === parentId)
|
|
476
|
+
addGroupDescendants(editor, child.id, ids);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
/**
|
|
480
|
+
* Snapshot positions for the upcoming multi-shape drag. Populated when the press
|
|
481
|
+
* lands on an already-selected shape (drag the whole selection with descendants)
|
|
482
|
+
* or on a group/frame (drag it and its members).
|
|
483
|
+
*/
|
|
484
|
+
const snapshotGroupMove = (editor, target) => {
|
|
485
|
+
if (target.kind === "element") {
|
|
486
|
+
const pressedElement = getElement(editor._scene, target.id);
|
|
487
|
+
const pressedIsGroup = pressedElement !== undefined && isGroup(pressedElement);
|
|
488
|
+
const pressedIsFrame = pressedElement !== undefined && isFrame(pressedElement);
|
|
489
|
+
const inSelection = editor._selection.has(target.id);
|
|
490
|
+
if (inSelection || pressedIsGroup || pressedIsFrame) {
|
|
491
|
+
const ids = new Set();
|
|
492
|
+
if (inSelection) {
|
|
493
|
+
// Locked / layer-locked elements stay put (still selectable).
|
|
494
|
+
for (const id of editor.expandSelectionWithDescendants()) {
|
|
495
|
+
const s = getElement(editor._scene, id);
|
|
496
|
+
if (s && editor.isElementManipulable(s))
|
|
497
|
+
ids.add(id);
|
|
266
498
|
}
|
|
267
499
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// to the rotate grip (it floats above the shape, overlapping the top
|
|
271
|
-
// anchor's grab zone — the grip must win there).
|
|
272
|
-
let target = editor.hitTest(worldPoint);
|
|
273
|
-
// Link-start anchor drag: a press on one of the single selected
|
|
274
|
-
// element's link-start dots begins an edge FROM that anchor without
|
|
275
|
-
// switching to the draw-edge tool. Checked before the normal hit-test
|
|
276
|
-
// / auto-select so the press isn't read as a body drag or lasso. The
|
|
277
|
-
// dots are the same world points the overlay draws (shared
|
|
278
|
-
// `anchorOverlayPoints`), so they're grabbable exactly where they
|
|
279
|
-
// appear. Modifier-clicks fall through to normal (additive) select.
|
|
280
|
-
if (editor.mode === "select" &&
|
|
281
|
-
!data.modifiers.shift &&
|
|
282
|
-
!data.modifiers.meta &&
|
|
283
|
-
!data.modifiers.ctrl &&
|
|
284
|
-
target.kind !== "rotate-handle") {
|
|
285
|
-
// Begin a link FROM a start dot of the single SELECTED element
|
|
286
|
-
// (connection dots only on the selected shape, so only its dots
|
|
287
|
-
// start a link). The dots sit OUTSIDE the shape; they're grabbable
|
|
288
|
-
// exactly where the overlay draws them (shared `anchorOverlayPoints`).
|
|
289
|
-
const tryAnchorDrag = (shapeId) => {
|
|
290
|
-
const shape = getElement(editor._scene, shapeId);
|
|
291
|
-
if (!shape)
|
|
292
|
-
return false;
|
|
293
|
-
const zoom = editor._scene.viewport.zoom || 1;
|
|
294
|
-
const { names, worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
295
|
-
const grab = (ANCHOR_DOT_ACTIVE_RADIUS + editor.anchorStartHitSlop) / zoom;
|
|
296
|
-
let bestName = null;
|
|
297
|
-
let bestD2 = grab * grab;
|
|
298
|
-
for (let i = 0; i < worldPoints.length; i++) {
|
|
299
|
-
const p = req(worldPoints[i]);
|
|
300
|
-
const dx = p.x - worldPoint.x;
|
|
301
|
-
const dy = p.y - worldPoint.y;
|
|
302
|
-
const d2 = dx * dx + dy * dy;
|
|
303
|
-
if (d2 <= bestD2) {
|
|
304
|
-
bestD2 = d2;
|
|
305
|
-
bestName = req(names[i]);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
if (bestName === null)
|
|
309
|
-
return false;
|
|
310
|
-
editor.cancelLongPress();
|
|
311
|
-
editor.linkDragFromAnchor = {
|
|
312
|
-
fromElement: shapeId,
|
|
313
|
-
fromWorld: getAnchorWorld(shape, { kind: "named", name: bestName }),
|
|
314
|
-
anchorName: bestName,
|
|
315
|
-
origin: worldPoint,
|
|
316
|
-
moved: false,
|
|
317
|
-
};
|
|
318
|
-
return true;
|
|
319
|
-
};
|
|
320
|
-
const selId = editor._selection.size === 1 ? [...editor._selection][0] : null;
|
|
321
|
-
if (selId && tryAnchorDrag(selId))
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
// Auto-select on press for shapes / edges that the user is about
|
|
325
|
-
// to act on (drag, resize handles): you can't manipulate an
|
|
326
|
-
// element that isn't selected, so pressing on an unselected one
|
|
327
|
-
// promotes it to the selection BEFORE the drag starts. Shift/Cmd
|
|
328
|
-
// extends instead of replacing. We don't promote inside the
|
|
329
|
-
// group-handle / edge-endpoint paths because those already act on
|
|
330
|
-
// the existing selection.
|
|
331
|
-
// Cmd/Ctrl-click on a shape that carries a (safe) link is a
|
|
332
|
-
// link-open gesture, NOT additive selection — don't mutate the
|
|
333
|
-
// selection on press; `onUp` opens the URL on a tap.
|
|
334
|
-
const linkModifier = data.modifiers.meta || data.modifiers.ctrl;
|
|
335
|
-
const isLinkOpen = linkModifier && target.kind === "element" && editor.elementLink(target.id) !== null;
|
|
336
|
-
if (!isLinkOpen && target.kind === "element" && !editor._selection.has(target.id)) {
|
|
337
|
-
const additive = data.modifiers.shift || data.modifiers.meta || data.modifiers.ctrl;
|
|
338
|
-
editor._selection = additive
|
|
339
|
-
? Selection.add(editor._selection, target.id)
|
|
340
|
-
: Selection.single(target.id);
|
|
341
|
-
// Remember an additive promotion so a tap's up-handler doesn't
|
|
342
|
-
// SELECT_TOGGLE it back off.
|
|
343
|
-
if (additive)
|
|
344
|
-
editor.additivePressAdded = target.id;
|
|
345
|
-
// Plain (non-additive) press on an element replaces the whole
|
|
346
|
-
// selection, so drop any selected links; an additive press keeps them.
|
|
347
|
-
if (!additive && editor._selectedLinks.size > 0) {
|
|
348
|
-
editor._selectedLinks = LinkSelection.EMPTY;
|
|
500
|
+
if (pressedIsGroup) {
|
|
501
|
+
addGroupDescendants(editor, target.id, ids);
|
|
349
502
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// descendants); the drag below then moves the clones, leaving the
|
|
358
|
-
// originals. Runs after auto-select so the press target is in the
|
|
359
|
-
// selection. `target` is re-pointed to the clone so the snapshot drags it.
|
|
360
|
-
// (An ⌥-press without a drag leaves a stacked clone — undoable.)
|
|
361
|
-
if (data.modifiers.alt &&
|
|
362
|
-
editor.mode === "select" &&
|
|
363
|
-
target.kind === "element" &&
|
|
364
|
-
editor._selection.has(target.id)) {
|
|
365
|
-
const clone = editor.duplicateSelectedInPlace(target.id);
|
|
366
|
-
if (clone !== null)
|
|
367
|
-
target = { kind: "element", id: clone, bounds: target.bounds };
|
|
368
|
-
}
|
|
369
|
-
// Track the dragged shape id for container drop / drag-out logic
|
|
370
|
-
// on pointerup. Cleared in onUp / cancel.
|
|
371
|
-
editor.dragElementId = target.kind === "element" ? target.id : null;
|
|
372
|
-
editor.containerHover = null;
|
|
373
|
-
// Snapshot positions for the upcoming drag. Two paths populate the
|
|
374
|
-
// group-move snapshot:
|
|
375
|
-
// 1. Press lands on an already-selected shape → drag the whole
|
|
376
|
-
// selection (with descendants of any selected group).
|
|
377
|
-
// 2. Press lands on a group shape (whether selected or not) →
|
|
378
|
-
// drag that group and its descendants. Without this, a click-
|
|
379
|
-
// drag on an unselected group would only move the wrapper
|
|
380
|
-
// (zero-bounds, invisible) and leave its children behind —
|
|
381
|
-
// looking exactly like the group had been ungrouped.
|
|
382
|
-
if (target.kind === "element") {
|
|
383
|
-
const pressedElement = getElement(editor._scene, target.id);
|
|
384
|
-
const pressedIsGroup = pressedElement !== undefined && isGroup(pressedElement);
|
|
385
|
-
const pressedIsFrame = pressedElement !== undefined && isFrame(pressedElement);
|
|
386
|
-
const inSelection = editor._selection.has(target.id);
|
|
387
|
-
if (inSelection || pressedIsGroup || pressedIsFrame) {
|
|
388
|
-
const ids = new Set();
|
|
389
|
-
if (inSelection) {
|
|
390
|
-
// Locked / layer-locked elements stay put (still selectable).
|
|
391
|
-
for (const id of editor.expandSelectionWithDescendants()) {
|
|
392
|
-
const s = getElement(editor._scene, id);
|
|
393
|
-
if (s && editor.isElementManipulable(s))
|
|
394
|
-
ids.add(id);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (pressedIsGroup) {
|
|
398
|
-
const visit = (parentId) => {
|
|
399
|
-
if (ids.has(parentId))
|
|
400
|
-
return;
|
|
401
|
-
ids.add(parentId);
|
|
402
|
-
for (const child of editor._scene.elements.values()) {
|
|
403
|
-
if (child.parentId === parentId)
|
|
404
|
-
visit(child.id);
|
|
405
|
-
}
|
|
406
|
-
};
|
|
407
|
-
visit(target.id);
|
|
408
|
-
}
|
|
409
|
-
if (pressedIsFrame) {
|
|
410
|
-
// Frame drag pulls every shape with matching frameId along.
|
|
411
|
-
// Frames are flat associations — no recursive descent needed.
|
|
412
|
-
ids.add(target.id);
|
|
413
|
-
for (const s of editor._scene.elements.values()) {
|
|
414
|
-
if (s.frameId === target.id)
|
|
415
|
-
ids.add(s.id);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
// Links that ride along with this drag: selected links (moved
|
|
419
|
-
// whole) + connectors bound on both ends to the dragged elements
|
|
420
|
-
// (geometry follows). Snapshotted at press so frames don't compound.
|
|
421
|
-
const movingLinks = snapshotMovingLinks(editor._scene, ids, editor._selectedLinks);
|
|
422
|
-
// Start a group drag for a real multi-selection OR whenever links
|
|
423
|
-
// need to ride along (even with a single dragged element).
|
|
424
|
-
if (ids.size > 1 || movingLinks.size > 0) {
|
|
425
|
-
const snap = new Map();
|
|
426
|
-
for (const id of ids) {
|
|
427
|
-
const s = getElement(editor._scene, id);
|
|
428
|
-
if (s)
|
|
429
|
-
snap.set(id, s.position);
|
|
430
|
-
}
|
|
431
|
-
editor.groupMoveOrigin = snap;
|
|
432
|
-
editor.groupLinkMoveOrigin = movingLinks.size > 0 ? movingLinks : null;
|
|
503
|
+
if (pressedIsFrame) {
|
|
504
|
+
// Frame drag pulls every shape with matching frameId along. Frames are
|
|
505
|
+
// flat associations — no recursive descent needed.
|
|
506
|
+
ids.add(target.id);
|
|
507
|
+
for (const s of editor._scene.elements.values()) {
|
|
508
|
+
if (s.frameId === target.id)
|
|
509
|
+
ids.add(s.id);
|
|
433
510
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
511
|
+
}
|
|
512
|
+
// Links that ride along: selected links (moved whole) + connectors bound
|
|
513
|
+
// on both ends to the dragged elements. Snapshotted at press.
|
|
514
|
+
const movingLinks = snapshotMovingLinks(editor._scene, ids, editor._selectedLinks);
|
|
515
|
+
// Start a group drag for a real multi-selection OR whenever links need to
|
|
516
|
+
// ride along (even with a single dragged element).
|
|
517
|
+
if (ids.size > 1 || movingLinks.size > 0) {
|
|
518
|
+
const snap = new Map();
|
|
519
|
+
for (const id of ids) {
|
|
520
|
+
const s = getElement(editor._scene, id);
|
|
521
|
+
if (s)
|
|
522
|
+
snap.set(id, s.position);
|
|
437
523
|
}
|
|
524
|
+
editor.groupMoveOrigin = snap;
|
|
525
|
+
editor.groupLinkMoveOrigin = movingLinks.size > 0 ? movingLinks : null;
|
|
438
526
|
}
|
|
439
527
|
else {
|
|
440
528
|
editor.groupMoveOrigin = null;
|
|
@@ -443,54 +531,636 @@ export const bindPointerEvents = (editor) => {
|
|
|
443
531
|
}
|
|
444
532
|
else {
|
|
445
533
|
editor.groupMoveOrigin = null;
|
|
534
|
+
editor.groupLinkMoveOrigin = null;
|
|
446
535
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
editor.groupMoveOrigin = null;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* Snapshot each member's world bounds + position + scale when the press lands on
|
|
543
|
+
* a group-handle so the per-frame resize math has a stable baseline.
|
|
544
|
+
*/
|
|
545
|
+
const snapshotGroupResize = (editor, target) => {
|
|
546
|
+
if (target.kind === "group-handle") {
|
|
547
|
+
const elements = new Map();
|
|
548
|
+
for (const id of editor.expandSelectionWithDescendants()) {
|
|
549
|
+
const s = getElement(editor._scene, id);
|
|
550
|
+
if (!s)
|
|
551
|
+
continue;
|
|
552
|
+
elements.set(id, {
|
|
553
|
+
position: s.position,
|
|
554
|
+
bounds: getElementWorldBounds(s),
|
|
555
|
+
scale: s.scale,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
// Links that scale with the box: selected links + connectors bound on both
|
|
559
|
+
// ends to the resized elements (same set as the move snapshot).
|
|
560
|
+
const links = snapshotMovingLinks(editor._scene, new Set(elements.keys()), editor._selectedLinks);
|
|
561
|
+
editor.groupResizeOrigin = { combined: target.bounds, elements, links };
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
editor.groupResizeOrigin = null;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
/**
|
|
568
|
+
* Snapshot every member's pose when the press lands on the rotate grip, so the
|
|
569
|
+
* per-frame rotate math turns from a stable baseline.
|
|
570
|
+
*/
|
|
571
|
+
const snapshotRotate = (editor, target) => {
|
|
572
|
+
if (target.kind === "rotate-handle") {
|
|
573
|
+
const origin = new Map();
|
|
574
|
+
for (const id of editor.expandSelectionWithDescendants()) {
|
|
575
|
+
const s = getElement(editor._scene, id);
|
|
576
|
+
if (s)
|
|
577
|
+
origin.set(id, { position: s.position, rotation: s.rotation });
|
|
578
|
+
}
|
|
579
|
+
editor.rotateGestureOrigin = { pivot: target.pivot, origin };
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
editor.rotateGestureOrigin = null;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// pointer-MOVE handlers
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
/**
|
|
589
|
+
* Pan gesture in flight — translate cursor delta to a screen pan and
|
|
590
|
+
* short-circuit. Doesn't touch the machine.
|
|
591
|
+
*/
|
|
592
|
+
const handleMovePan = (editor, ev, data) => {
|
|
593
|
+
const pan = editor.panGesture;
|
|
594
|
+
if (pan?.pointerId !== ev.pointerId)
|
|
595
|
+
return false;
|
|
596
|
+
const dx = data.point.x - pan.lastPoint.x;
|
|
597
|
+
const dy = data.point.y - pan.lastPoint.y;
|
|
598
|
+
pan.lastPoint = data.point;
|
|
599
|
+
// Mark as moved once total displacement crosses slop — used at pointerup to
|
|
600
|
+
// decide context-menu vs drag for right-click gestures.
|
|
601
|
+
if (!pan.moved && vec2.distance(pan.startPoint, data.point) > LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
602
|
+
pan.moved = true;
|
|
603
|
+
}
|
|
604
|
+
// Natural-grab direction: `viewportPanBy` already subtracts deltaScreen from
|
|
605
|
+
// pan, so pass the raw cursor delta — no extra inversion.
|
|
606
|
+
editor.panBy({ x: dx, y: dy });
|
|
607
|
+
return true;
|
|
608
|
+
};
|
|
609
|
+
/**
|
|
610
|
+
* Update the tracked pointer position; in pinch mode recompute the gesture and
|
|
611
|
+
* short-circuit before the machine sees the move.
|
|
612
|
+
*/
|
|
613
|
+
const handleMovePinch = (editor, ev, data) => {
|
|
614
|
+
if (editor.activePointers.has(ev.pointerId)) {
|
|
615
|
+
editor.activePointers.set(ev.pointerId, data.point);
|
|
616
|
+
}
|
|
617
|
+
if (editor.pinch.isActive()) {
|
|
618
|
+
editor.applyPinch();
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
};
|
|
623
|
+
/**
|
|
624
|
+
* One-finger pan: an armed touch press on empty canvas that has now dragged past
|
|
625
|
+
* slop becomes a pan (not a marquee lasso).
|
|
626
|
+
*/
|
|
627
|
+
const handleMoveOneFingerPan = (editor, ev, data) => {
|
|
628
|
+
if (editor.touchPanCandidate !== null &&
|
|
629
|
+
editor.activePointers.size === 1 &&
|
|
630
|
+
vec2.distance(editor.touchPanCandidate, data.point) > LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
631
|
+
const origin = editor.touchPanCandidate;
|
|
632
|
+
editor.touchPanCandidate = null;
|
|
633
|
+
editor.beginPanGesture(ev.pointerId, ev.button, origin);
|
|
634
|
+
const pan = editor.panGesture;
|
|
635
|
+
if (pan) {
|
|
636
|
+
pan.moved = true;
|
|
637
|
+
editor.panBy({ x: data.point.x - origin.x, y: data.point.y - origin.y });
|
|
638
|
+
pan.lastPoint = data.point;
|
|
639
|
+
}
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
};
|
|
644
|
+
/** Host-managed elbow segment drag of the selected link. */
|
|
645
|
+
const handleMoveSegmentDrag = (editor, worldPoint) => {
|
|
646
|
+
if (!editor.isDraggingSegment)
|
|
647
|
+
return false;
|
|
648
|
+
editor.updateSegmentDrag(worldPoint);
|
|
649
|
+
return true;
|
|
650
|
+
};
|
|
651
|
+
/** Host-managed waypoint (bend-point) drag of the selected link. */
|
|
652
|
+
const handleMoveWaypointDrag = (editor, worldPoint) => {
|
|
653
|
+
if (!editor.isDraggingWaypoint)
|
|
654
|
+
return false;
|
|
655
|
+
editor.updateWaypointDrag(worldPoint);
|
|
656
|
+
return true;
|
|
657
|
+
};
|
|
658
|
+
/**
|
|
659
|
+
* Link drag started from a start-anchor (select mode, no tool switch).
|
|
660
|
+
* Host-managed end-to-end — the machine never saw a POINTER_DOWN. Gated on the
|
|
661
|
+
* drag threshold so a click that barely moves is not a draw.
|
|
662
|
+
*/
|
|
663
|
+
const handleMoveLinkAnchorDrag = (editor, worldPoint) => {
|
|
664
|
+
const drag = editor.linkDragFromAnchor;
|
|
665
|
+
if (!drag)
|
|
666
|
+
return false;
|
|
667
|
+
if (!drag.moved) {
|
|
668
|
+
const dx = worldPoint.x - drag.origin.x;
|
|
669
|
+
const dy = worldPoint.y - drag.origin.y;
|
|
670
|
+
if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD)
|
|
671
|
+
return true;
|
|
672
|
+
drag.moved = true;
|
|
673
|
+
}
|
|
674
|
+
editor.applyLinkPreview(drag.fromElement, drag.fromWorld, worldPoint);
|
|
675
|
+
editor.updateHoveredLinkTarget(worldPoint);
|
|
676
|
+
return true;
|
|
677
|
+
};
|
|
678
|
+
/** Drag-select inside the edited text shape. */
|
|
679
|
+
const handleMoveTextDragSelect = (editor, worldPoint) => {
|
|
680
|
+
if (!(editor.editingTextElement !== null && editor.isTextDragging))
|
|
681
|
+
return false;
|
|
682
|
+
editor.extendTextSelectionToPoint(worldPoint);
|
|
683
|
+
return true;
|
|
684
|
+
};
|
|
685
|
+
/** Brush stroke in progress — append a vertex and skip everything else. */
|
|
686
|
+
const handleMoveBrush = (editor, worldPoint, pressure) => {
|
|
687
|
+
if (!editor.brushStroke)
|
|
688
|
+
return false;
|
|
689
|
+
editor.extendBrushStroke(worldPoint, pressure);
|
|
690
|
+
return true;
|
|
691
|
+
};
|
|
692
|
+
/** Eraser stroke in progress — sweep more shapes along the drag (Alt un-marks). */
|
|
693
|
+
const handleMoveErase = (editor, worldPoint, restore) => {
|
|
694
|
+
if (!editor.eraseStroke)
|
|
695
|
+
return false;
|
|
696
|
+
editor.extendEraseStroke(worldPoint, restore);
|
|
697
|
+
return true;
|
|
698
|
+
};
|
|
699
|
+
/** Laser trail in progress — append a point to the fading stroke. */
|
|
700
|
+
const handleMoveLaser = (editor, worldPoint) => {
|
|
701
|
+
if (!editor.laserDrawing)
|
|
702
|
+
return false;
|
|
703
|
+
editor.extendLaserStroke(worldPoint);
|
|
704
|
+
return true;
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* Container drop preview: while dragging a single shape, find the topmost
|
|
708
|
+
* container under cursor (excluding the dragged shape and its descendants) and
|
|
709
|
+
* stash the drop-zone for the overlay. Non-short-circuiting.
|
|
710
|
+
*/
|
|
711
|
+
const updateContainerDropPreview = (editor, worldPoint) => {
|
|
712
|
+
if (!editor.dragElementId)
|
|
713
|
+
return;
|
|
714
|
+
const dragged = editor.dragElementId;
|
|
715
|
+
const exclude = new Set([dragged]);
|
|
716
|
+
// Don't drop a container onto itself or into one of its own descendants.
|
|
717
|
+
for (const s of editor._scene.elements.values()) {
|
|
718
|
+
let cursor = s.parentId;
|
|
719
|
+
for (let i = 0; cursor && i < 64; i++) {
|
|
720
|
+
if (cursor === dragged) {
|
|
721
|
+
exclude.add(s.id);
|
|
722
|
+
break;
|
|
467
723
|
}
|
|
468
|
-
|
|
469
|
-
// both ends to the resized elements (same set as the move snapshot).
|
|
470
|
-
const links = snapshotMovingLinks(editor._scene, new Set(elements.keys()), editor._selectedLinks);
|
|
471
|
-
editor.groupResizeOrigin = { combined: target.bounds, elements, links };
|
|
724
|
+
cursor = editor._scene.elements.get(cursor)?.parentId;
|
|
472
725
|
}
|
|
473
|
-
|
|
474
|
-
|
|
726
|
+
}
|
|
727
|
+
const container = findContainerAt(editor._scene, worldPoint, exclude);
|
|
728
|
+
if (container) {
|
|
729
|
+
const zone = getDropZoneWorld(container);
|
|
730
|
+
if (zone) {
|
|
731
|
+
const next = { id: container.id, dropZone: zone };
|
|
732
|
+
if (editor.containerHover?.id !== next.id ||
|
|
733
|
+
editor.containerHover.dropZone !== next.dropZone) {
|
|
734
|
+
editor.containerHover = next;
|
|
735
|
+
editor.notify();
|
|
736
|
+
}
|
|
475
737
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
738
|
+
}
|
|
739
|
+
else if (editor.containerHover !== null) {
|
|
740
|
+
editor.containerHover = null;
|
|
741
|
+
editor.notify();
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
/**
|
|
745
|
+
* Annotation drag — update annotation position from delta. No patches per-move;
|
|
746
|
+
* commit on pointerup so undo is one step.
|
|
747
|
+
*/
|
|
748
|
+
const handleMoveAnnotationDrag = (editor, worldPoint) => {
|
|
749
|
+
const drag = editor.annotationDrag;
|
|
750
|
+
if (!drag)
|
|
751
|
+
return false;
|
|
752
|
+
const dx = worldPoint.x - drag.originWorldPoint.x;
|
|
753
|
+
const dy = worldPoint.y - drag.originWorldPoint.y;
|
|
754
|
+
if (dx !== 0 || dy !== 0)
|
|
755
|
+
drag.moved = true;
|
|
756
|
+
const ann = editor._scene.annotations.get(drag.id);
|
|
757
|
+
if (ann) {
|
|
758
|
+
// Mutate via apply to keep render in sync; final commit on up rewrites the
|
|
759
|
+
// patch from origin to final.
|
|
760
|
+
const newPos = { x: drag.originPosition.x + dx, y: drag.originPosition.y + dy };
|
|
761
|
+
const next = { ...ann, position: newPos };
|
|
762
|
+
const annotations = new Map(editor._scene.annotations);
|
|
763
|
+
annotations.set(drag.id, next);
|
|
764
|
+
editor._scene = { ...editor._scene, annotations };
|
|
765
|
+
editor.notify();
|
|
766
|
+
}
|
|
767
|
+
return true;
|
|
768
|
+
};
|
|
769
|
+
/**
|
|
770
|
+
* Idle / drawing tail of onMove: fan out the cursor, update rubber-band /
|
|
771
|
+
* draw-edge previews and idle hover affordances, then send POINTER_MOVE.
|
|
772
|
+
*/
|
|
773
|
+
const dispatchMoveToMachine = (editor, worldPoint) => {
|
|
774
|
+
// Fan out to anyone listening for the local cursor — `@collab` broadcasts it
|
|
775
|
+
// via awareness. Subscribers throttle if they care.
|
|
776
|
+
for (const fn of editor.cursorListeners)
|
|
777
|
+
fn(worldPoint);
|
|
778
|
+
const ctx = editor.actor.getSnapshot().context;
|
|
779
|
+
if (ctx.pressOrigin &&
|
|
780
|
+
ctx.mode !== "select" &&
|
|
781
|
+
ctx.mode !== "draw-edge" &&
|
|
782
|
+
editor.isDrawingPhase(ctx)) {
|
|
783
|
+
// Update rubber-band preview live for rect / ellipse drawing.
|
|
784
|
+
editor.drawingPreview = boundsFromPoints(ctx.pressOrigin, worldPoint);
|
|
785
|
+
}
|
|
786
|
+
// Port-overlay tracking in draw-edge mode — both when idle and during gesture.
|
|
787
|
+
if (ctx.mode === "draw-edge") {
|
|
788
|
+
editor.updateHoveredLinkTarget(worldPoint);
|
|
789
|
+
}
|
|
790
|
+
else if (editor.hoveredLinkTarget !== null) {
|
|
791
|
+
editor.hoveredLinkTarget = null;
|
|
792
|
+
editor.notify();
|
|
793
|
+
}
|
|
794
|
+
// Hover-to-play: while idle and directly over an animated image, signal hover
|
|
795
|
+
// so a paused GIF can resume.
|
|
796
|
+
if (!ctx.pressOrigin) {
|
|
797
|
+
const hov = editor.hitTest(worldPoint);
|
|
798
|
+
const directHs = hov.kind === "element" ? editor._scene.elements.get(hov.id) : undefined;
|
|
799
|
+
editor.hoverAnimatedElement(directHs && isImage(directHs) && directHs.animationKind ? directHs.id : null);
|
|
800
|
+
// Track the idle cursor so the SINGLE selected element's link-start dot
|
|
801
|
+
// grows by proximity. Only the selected element's dots react.
|
|
802
|
+
editor.setHoverCursorWorld(editor.mode === "select" ? worldPoint : null);
|
|
803
|
+
// Erase-mode hover: repaint so the eraser cursor ring follows the pointer
|
|
804
|
+
// (its centre is `lastPointerWorld`, already updated above). Drag moves
|
|
805
|
+
// repaint via `extendEraseStroke`; this covers the pointer-up hover.
|
|
806
|
+
if (editor.mode === "erase")
|
|
807
|
+
editor.notify();
|
|
808
|
+
}
|
|
809
|
+
editor.actor.send({ type: "POINTER_MOVE", point: worldPoint });
|
|
810
|
+
};
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// pointer-UP handlers
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
/** Pan gesture ends — clean up cursor and state, skip the rest. */
|
|
815
|
+
const handleUpPan = (editor, ev) => {
|
|
816
|
+
if (editor.panGesture?.pointerId !== ev.pointerId)
|
|
817
|
+
return false;
|
|
818
|
+
editor.endPanGesture();
|
|
819
|
+
return true;
|
|
820
|
+
};
|
|
821
|
+
/**
|
|
822
|
+
* Exit pinch when the second-to-last finger lifts. The surviving touch does NOT
|
|
823
|
+
* resume as a single-finger drag (machine was cancelled on pinch entry).
|
|
824
|
+
*/
|
|
825
|
+
const handleUpPinch = (editor) => {
|
|
826
|
+
if (!editor.pinch.isActive())
|
|
827
|
+
return false;
|
|
828
|
+
if (editor.activePointers.size < 2)
|
|
829
|
+
editor.pinch.end();
|
|
830
|
+
return true;
|
|
831
|
+
};
|
|
832
|
+
/** Commit a host-managed elbow segment drag (one undo step). */
|
|
833
|
+
const handleUpSegmentDrag = (editor) => {
|
|
834
|
+
if (!editor.isDraggingSegment)
|
|
835
|
+
return false;
|
|
836
|
+
editor.endSegmentDrag();
|
|
837
|
+
return true;
|
|
838
|
+
};
|
|
839
|
+
/** Commit a host-managed waypoint drag (collapses if dropped onto the line). */
|
|
840
|
+
const handleUpWaypointDrag = (editor) => {
|
|
841
|
+
if (!editor.isDraggingWaypoint)
|
|
842
|
+
return false;
|
|
843
|
+
editor.endWaypointDrag();
|
|
844
|
+
return true;
|
|
845
|
+
};
|
|
846
|
+
/**
|
|
847
|
+
* Commit a link drag that began from a start-anchor. If it moved past the
|
|
848
|
+
* threshold, create the edge. If not, distinguish a click exactly ON the dot
|
|
849
|
+
* (spawn a new element + link) from a click in the wider grab halo (normal
|
|
850
|
+
* select / deselect). Either way clear the preview/hover.
|
|
851
|
+
*/
|
|
852
|
+
const handleUpLinkAnchorDrag = (editor, ev) => {
|
|
853
|
+
const drag = editor.linkDragFromAnchor;
|
|
854
|
+
if (!drag)
|
|
855
|
+
return false;
|
|
856
|
+
editor.linkDragFromAnchor = null;
|
|
857
|
+
const upData = fromPointerEvent(ev, editor.host);
|
|
858
|
+
const upWorld = editor.screenToWorld(upData.point);
|
|
859
|
+
if (drag.moved) {
|
|
860
|
+
const upHit = editor.hitTest(upWorld);
|
|
861
|
+
const toElement = upHit.kind === "element" ? upHit.id : null;
|
|
862
|
+
editor.applyEmit({
|
|
863
|
+
type: "CREATE_EDGE",
|
|
864
|
+
fromElement: drag.fromElement,
|
|
865
|
+
toElement,
|
|
866
|
+
fromPoint: drag.fromWorld,
|
|
867
|
+
toPoint: upWorld,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
// A click, not a draw. If it landed exactly ON the dot (narrow radius),
|
|
872
|
+
// spawn a new element + link in that dot's direction. Otherwise it was a
|
|
873
|
+
// click in the wider grab halo → normal select / deselect by hit-test.
|
|
874
|
+
const selShape = getElement(editor._scene, drag.fromElement);
|
|
875
|
+
const zoom = editor._scene.viewport.zoom || 1;
|
|
876
|
+
let onDot = false;
|
|
877
|
+
if (selShape) {
|
|
878
|
+
const { names, worldPoints } = anchorOverlayPoints(selShape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
879
|
+
const idx = names.indexOf(drag.anchorName);
|
|
880
|
+
if (idx >= 0) {
|
|
881
|
+
const dp = req(worldPoints[idx]);
|
|
882
|
+
const r = editor.anchorClickRadius / zoom;
|
|
883
|
+
const dx = dp.x - drag.origin.x;
|
|
884
|
+
const dy = dp.y - drag.origin.y;
|
|
885
|
+
onDot = dx * dx + dy * dy <= r * r;
|
|
485
886
|
}
|
|
486
|
-
|
|
887
|
+
}
|
|
888
|
+
if (onDot) {
|
|
889
|
+
editor.createLinkedElementFromAnchor(drag.fromElement, drag.anchorName);
|
|
487
890
|
}
|
|
488
891
|
else {
|
|
489
|
-
|
|
892
|
+
const upHit = editor.hitTest(upWorld);
|
|
893
|
+
if (upHit.kind === "empty")
|
|
894
|
+
editor.applyEmit({ type: "SELECT_CLEAR" });
|
|
895
|
+
else if (upHit.kind === "element")
|
|
896
|
+
editor.applyEmit({ type: "SELECT_REPLACE", id: upHit.id });
|
|
897
|
+
else if (upHit.kind === "link")
|
|
898
|
+
editor.applyEmit({ type: "SELECT_EDGE_REPLACE", id: upHit.id });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
editor.edgePreview = null;
|
|
902
|
+
editor.hoveredLinkTarget = null;
|
|
903
|
+
editor.notify();
|
|
904
|
+
return true;
|
|
905
|
+
};
|
|
906
|
+
/** End an in-canvas text drag-select. */
|
|
907
|
+
const handleUpTextDragSelect = (editor) => {
|
|
908
|
+
if (!(editor.editingTextElement !== null && editor.isTextDragging))
|
|
909
|
+
return false;
|
|
910
|
+
editor.endTextDragSelect();
|
|
911
|
+
return true;
|
|
912
|
+
};
|
|
913
|
+
/** Commit brush stroke if one is in progress. */
|
|
914
|
+
const handleUpBrush = (editor) => {
|
|
915
|
+
if (!editor.brushStroke)
|
|
916
|
+
return false;
|
|
917
|
+
editor.commitBrushStroke();
|
|
918
|
+
return true;
|
|
919
|
+
};
|
|
920
|
+
/** Commit eraser stroke — delete every swept shape in one undo step. */
|
|
921
|
+
const handleUpErase = (editor) => {
|
|
922
|
+
if (!editor.eraseStroke)
|
|
923
|
+
return false;
|
|
924
|
+
editor.commitEraseStroke();
|
|
925
|
+
return true;
|
|
926
|
+
};
|
|
927
|
+
/** End the laser gesture — the trail keeps fading on its own. */
|
|
928
|
+
const handleUpLaser = (editor) => {
|
|
929
|
+
if (!editor.laserDrawing)
|
|
930
|
+
return false;
|
|
931
|
+
editor.endLaserStroke();
|
|
932
|
+
return true;
|
|
933
|
+
};
|
|
934
|
+
/**
|
|
935
|
+
* Annotation drag commit — issue a single patch from origin to final position so
|
|
936
|
+
* history has one undo step.
|
|
937
|
+
*/
|
|
938
|
+
const handleUpAnnotationDrag = (editor) => {
|
|
939
|
+
const drag = editor.annotationDrag;
|
|
940
|
+
if (!drag)
|
|
941
|
+
return false;
|
|
942
|
+
editor.annotationDrag = null;
|
|
943
|
+
if (drag.moved) {
|
|
944
|
+
const final = editor._scene.annotations.get(drag.id);
|
|
945
|
+
if (final) {
|
|
946
|
+
// Reset to origin, then issue patch with proper before/after.
|
|
947
|
+
const origin = { ...final, position: drag.originPosition };
|
|
948
|
+
const annotations = new Map(editor._scene.annotations);
|
|
949
|
+
annotations.set(drag.id, origin);
|
|
950
|
+
editor._scene = { ...editor._scene, annotations };
|
|
951
|
+
const r = updateAnnotation(editor._scene, drag.id, () => final);
|
|
952
|
+
editor._scene = r.scene;
|
|
953
|
+
editor._history.push(r.patch);
|
|
954
|
+
editor.notify();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return true;
|
|
958
|
+
};
|
|
959
|
+
/**
|
|
960
|
+
* Cmd/Ctrl-click on a linked element (a tap, not a drag) opens its href — onDown
|
|
961
|
+
* already skipped the additive-select promote for this case, so selection is
|
|
962
|
+
* untouched. Returns true when a link was opened.
|
|
963
|
+
*/
|
|
964
|
+
const handleUpLinkOpen = (editor, data, worldPoint) => {
|
|
965
|
+
const origin = editor.actor.getSnapshot().context.pressOrigin;
|
|
966
|
+
const linkMod = data.modifiers.meta || data.modifiers.ctrl;
|
|
967
|
+
if (origin && linkMod) {
|
|
968
|
+
const zoom = editor._scene.viewport.zoom || 1;
|
|
969
|
+
const movedPx = Math.hypot(worldPoint.x - origin.x, worldPoint.y - origin.y) * zoom;
|
|
970
|
+
if (movedPx < LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
971
|
+
const hit = editor.hitTest(worldPoint);
|
|
972
|
+
if (hit.kind === "element") {
|
|
973
|
+
const href = editor.elementLink(hit.id);
|
|
974
|
+
if (href) {
|
|
975
|
+
editor.openLink(href);
|
|
976
|
+
editor.actor.send({ type: "POINTER_UP", point: worldPoint });
|
|
977
|
+
editor.commitGesture();
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
490
981
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
982
|
+
}
|
|
983
|
+
return false;
|
|
984
|
+
};
|
|
985
|
+
/**
|
|
986
|
+
* Normal pointer-up tail: fire the click-style effect, route group-isolation,
|
|
987
|
+
* dispatch POINTER_UP to the machine, apply container/frame reparenting, commit
|
|
988
|
+
* the gesture, then handle GIF-tap playback and the idle cursor.
|
|
989
|
+
*/
|
|
990
|
+
const handleUpCommit = (editor, worldPoint) => {
|
|
991
|
+
// First, fire any click-style effect derived from the press context.
|
|
992
|
+
const ctxBeforeUp = editor.actor.getSnapshot().context;
|
|
993
|
+
let clickEffect = interpretPressEnd(ctxBeforeUp, worldPoint);
|
|
994
|
+
// A shift/meta TAP on a shape the press already added additively must NOT
|
|
995
|
+
// toggle it back off — the press handled the add, this would undo it.
|
|
996
|
+
if (clickEffect?.type === "SELECT_TOGGLE" && editor.additivePressAdded === clickEffect.id) {
|
|
997
|
+
clickEffect = null;
|
|
998
|
+
}
|
|
999
|
+
editor.additivePressAdded = null;
|
|
1000
|
+
// Group isolation routing overrides what `interpretPressEnd` produced.
|
|
1001
|
+
const handledByIsolation = editor.routeIsolationClick(clickEffect, worldPoint);
|
|
1002
|
+
if (!handledByIsolation && clickEffect) {
|
|
1003
|
+
editor.applyEmit(clickEffect);
|
|
1004
|
+
}
|
|
1005
|
+
editor.drawingPreview = null;
|
|
1006
|
+
// Provide the up-side hit-test when the gesture cares about where it lands:
|
|
1007
|
+
// drawing a new edge (normal hit-test resolves the drop), or re-binding an
|
|
1008
|
+
// existing endpoint (element-only attach hit-test so the drop binds to the
|
|
1009
|
+
// shape / anchor rather than the dragged endpoint handle at the cursor).
|
|
1010
|
+
let upTarget;
|
|
1011
|
+
if (ctxBeforeUp.mode === "draw-edge") {
|
|
1012
|
+
upTarget = editor.hitTest(worldPoint);
|
|
1013
|
+
}
|
|
1014
|
+
else if (ctxBeforeUp.pressTarget?.kind === "edge-endpoint") {
|
|
1015
|
+
upTarget = editor.linkAttachTargetAt(worldPoint);
|
|
1016
|
+
}
|
|
1017
|
+
editor.actor.send(upTarget !== undefined
|
|
1018
|
+
? { type: "POINTER_UP", point: worldPoint, target: upTarget }
|
|
1019
|
+
: { type: "POINTER_UP", point: worldPoint });
|
|
1020
|
+
// Container reparent / drag-out and frame membership — both before commit so
|
|
1021
|
+
// the parentId / autoGrow / frameId patches land in the same undo step.
|
|
1022
|
+
editor.applyContainerDrop(worldPoint);
|
|
1023
|
+
editor.reconcileFrameMembership();
|
|
1024
|
+
editor.commitGesture();
|
|
1025
|
+
// A tap (not a drag) on an animated image toggles its GIF playback. Gated on
|
|
1026
|
+
// near-zero displacement so it never fires at the end of a drag.
|
|
1027
|
+
const origin = ctxBeforeUp.pressOrigin;
|
|
1028
|
+
if (origin) {
|
|
1029
|
+
const zoom = editor._scene.viewport.zoom || 1;
|
|
1030
|
+
const movedPx = Math.hypot(worldPoint.x - origin.x, worldPoint.y - origin.y) * zoom;
|
|
1031
|
+
if (movedPx < LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
1032
|
+
const hit = editor.hitTest(worldPoint);
|
|
1033
|
+
if (hit.kind === "element") {
|
|
1034
|
+
const s = editor._scene.elements.get(hit.id);
|
|
1035
|
+
if (s && isImage(s) && s.animationKind)
|
|
1036
|
+
editor.togglePlayback(s.id);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
// The gesture is over — recompute the cursor for the now-idle hover state.
|
|
1041
|
+
editor.refreshCursor(worldPoint);
|
|
1042
|
+
};
|
|
1043
|
+
// ---------------------------------------------------------------------------
|
|
1044
|
+
// wheel handlers
|
|
1045
|
+
// ---------------------------------------------------------------------------
|
|
1046
|
+
/**
|
|
1047
|
+
* Zoom around `screenPoint` from the wheel's `deltaY`. The raw delta is clamped
|
|
1048
|
+
* to MAX_STEP so a mouse-wheel notch is a calm ~10 % step, while trackpad pinch
|
|
1049
|
+
* events (|deltaY| of 2–5) bypass the clamp and stay granular for smooth zooms.
|
|
1050
|
+
*/
|
|
1051
|
+
const applyWheelZoom = (editor, ev, screenPoint) => {
|
|
1052
|
+
if (ev.deltaY === 0)
|
|
1053
|
+
return;
|
|
1054
|
+
const clampedDelta = Math.abs(ev.deltaY) > WHEEL_ZOOM_MAX_STEP
|
|
1055
|
+
? WHEEL_ZOOM_MAX_STEP * Math.sign(ev.deltaY)
|
|
1056
|
+
: ev.deltaY;
|
|
1057
|
+
const factor = 1 - (clampedDelta * WHEEL_ZOOM_SPEED) / 100;
|
|
1058
|
+
if (factor <= 0)
|
|
1059
|
+
return;
|
|
1060
|
+
const currentZoom = editor._scene.viewport.zoom;
|
|
1061
|
+
const nextZoom = clampZoom(currentZoom * factor);
|
|
1062
|
+
if (nextZoom === currentZoom)
|
|
1063
|
+
return;
|
|
1064
|
+
const anchor = editor.screenToWorld(screenPoint);
|
|
1065
|
+
editor.zoomAt(nextZoom / currentZoom, anchor);
|
|
1066
|
+
};
|
|
1067
|
+
/**
|
|
1068
|
+
* Pan both axes from the wheel deltas. Shift + vertical-only wheel maps the
|
|
1069
|
+
* vertical delta to horizontal pan. `panBy` subtracts deltaScreen from pan, so
|
|
1070
|
+
* the wheel delta is negated to match native browser scroll feel.
|
|
1071
|
+
*/
|
|
1072
|
+
const applyWheelPan = (editor, ev) => {
|
|
1073
|
+
let dx = ev.deltaX;
|
|
1074
|
+
let dy = ev.deltaY;
|
|
1075
|
+
if (ev.shiftKey && dx === 0) {
|
|
1076
|
+
dx = dy;
|
|
1077
|
+
dy = 0;
|
|
1078
|
+
}
|
|
1079
|
+
editor.panBy({ x: -dx * WHEEL_PAN_FACTOR, y: -dy * WHEEL_PAN_FACTOR });
|
|
1080
|
+
};
|
|
1081
|
+
/**
|
|
1082
|
+
* Pointer + wheel event binding. Owns the branchy dispatch — pan / pinch /
|
|
1083
|
+
* brush / annotation / interactive-hit / machine flow — delegating each case to
|
|
1084
|
+
* the module-level handlers above so no single handler is a monolith.
|
|
1085
|
+
*
|
|
1086
|
+
* Returns an unsubscribe function that removes every listener it installed.
|
|
1087
|
+
*
|
|
1088
|
+
* Typed against the full `Editor` class (type-only import — erased at runtime,
|
|
1089
|
+
* so no import cycle and dependency-cruiser ignores it). This handler reaches
|
|
1090
|
+
* deep into Editor's surface, so the members it touches are declared `public`
|
|
1091
|
+
* (with the `_`-prefix convention marking them internal). editor.ts is the only
|
|
1092
|
+
* call site.
|
|
1093
|
+
*/
|
|
1094
|
+
export const bindPointerEvents = (editor) => {
|
|
1095
|
+
const onDown = (ev) => {
|
|
1096
|
+
ev.preventDefault();
|
|
1097
|
+
editor.host.setPointerCapture(ev.pointerId);
|
|
1098
|
+
// Give the canvas keyboard focus on press. `preventDefault` above suppresses
|
|
1099
|
+
// the browser's default focus-on-pointerdown, so without this the surface
|
|
1100
|
+
// never focuses by clicking. Skipped when the press lands on a text field
|
|
1101
|
+
// (in-canvas text editing) so it keeps its own focus.
|
|
1102
|
+
if (typeof editor.host.focus === "function" && !isEditableTarget(ev.target)) {
|
|
1103
|
+
editor.host.focus({ preventScroll: true });
|
|
1104
|
+
}
|
|
1105
|
+
const data = fromPointerEvent(ev, editor.host);
|
|
1106
|
+
// Fresh press — forget any additive promotion from the last gesture.
|
|
1107
|
+
editor.additivePressAdded = null;
|
|
1108
|
+
// Pan detection must come BEFORE the normal flow, and multi-pointer pinch
|
|
1109
|
+
// tracking BEFORE the machine sees a single-pointer gesture.
|
|
1110
|
+
if (handleDownPanTrigger(editor, ev, data))
|
|
1111
|
+
return;
|
|
1112
|
+
if (handleDownMultiPointer(editor, ev, data))
|
|
1113
|
+
return;
|
|
1114
|
+
// Schedule a long-press fire — cancelled by movement or release.
|
|
1115
|
+
editor.startLongPress(data.point);
|
|
1116
|
+
const worldPoint = editor.screenToWorld(data.point);
|
|
1117
|
+
// Mode / target-specific take-overs (each short-circuits when it consumes
|
|
1118
|
+
// the press). Order matters — it mirrors the original monolith exactly.
|
|
1119
|
+
// An armed colour-picker pipette captures the next canvas click first,
|
|
1120
|
+
// regardless of the current tool mode.
|
|
1121
|
+
if (handleDownEyedropper(editor, worldPoint))
|
|
1122
|
+
return;
|
|
1123
|
+
if (handleDownEditingText(editor, worldPoint))
|
|
1124
|
+
return;
|
|
1125
|
+
if (handleDownCrop(editor, worldPoint))
|
|
1126
|
+
return;
|
|
1127
|
+
if (handleDownCropEnter(editor, worldPoint, ev.detail))
|
|
1128
|
+
return;
|
|
1129
|
+
if (handleDownBrush(editor, worldPoint, ev.pressure))
|
|
1130
|
+
return;
|
|
1131
|
+
if (handleDownErase(editor, worldPoint, data.modifiers.alt, data.modifiers.shift))
|
|
1132
|
+
return;
|
|
1133
|
+
if (handleDownLaser(editor, worldPoint))
|
|
1134
|
+
return;
|
|
1135
|
+
if (handleDownDrawText(editor, worldPoint))
|
|
1136
|
+
return;
|
|
1137
|
+
if (handleDownAnnotation(editor, worldPoint))
|
|
1138
|
+
return;
|
|
1139
|
+
if (handleDownInteractiveHit(editor, worldPoint))
|
|
1140
|
+
return;
|
|
1141
|
+
if (handleDownSegmentDrag(editor, worldPoint))
|
|
1142
|
+
return;
|
|
1143
|
+
if (handleDownWaypointDrag(editor, worldPoint))
|
|
1144
|
+
return;
|
|
1145
|
+
// Resolve the press target up-front so the anchor-drag path can defer to the
|
|
1146
|
+
// rotate grip (which floats above the top anchor's grab zone).
|
|
1147
|
+
let target = editor.hitTest(worldPoint);
|
|
1148
|
+
if (handleDownAnchorStart(editor, data, worldPoint, target))
|
|
1149
|
+
return;
|
|
1150
|
+
applyAutoSelect(editor, data, target);
|
|
1151
|
+
// ⌥-drag duplicate runs after auto-select so the press target is in the
|
|
1152
|
+
// selection; `target` re-points to the clone so the snapshot drags it.
|
|
1153
|
+
target = applyAltDragDuplicate(editor, data, target);
|
|
1154
|
+
// Track the dragged shape id for container drop / drag-out logic on
|
|
1155
|
+
// pointerup. Cleared in onUp / cancel.
|
|
1156
|
+
editor.dragElementId = target.kind === "element" ? target.id : null;
|
|
1157
|
+
editor.containerHover = null;
|
|
1158
|
+
snapshotGroupMove(editor, target);
|
|
1159
|
+
snapshotGroupResize(editor, target);
|
|
1160
|
+
snapshotRotate(editor, target);
|
|
1161
|
+
// Arm one-finger pan: a TOUCH press on empty canvas in select mode. A tap
|
|
1162
|
+
// still deselects via the machine below; onMove promotes this to a pan once
|
|
1163
|
+
// the finger drags past slop.
|
|
494
1164
|
editor.touchPanCandidate =
|
|
495
1165
|
data.kind === "touch" && editor.mode === "select" && target.kind === "empty"
|
|
496
1166
|
? data.point
|
|
@@ -504,419 +1174,77 @@ export const bindPointerEvents = (editor) => {
|
|
|
504
1174
|
};
|
|
505
1175
|
const onMove = (ev) => {
|
|
506
1176
|
const data = fromPointerEvent(ev, editor.host);
|
|
507
|
-
|
|
508
|
-
// pan and short-circuit. Doesn't touch the machine.
|
|
509
|
-
if (editor.panGesture?.pointerId === ev.pointerId) {
|
|
510
|
-
const dx = data.point.x - editor.panGesture.lastPoint.x;
|
|
511
|
-
const dy = data.point.y - editor.panGesture.lastPoint.y;
|
|
512
|
-
editor.panGesture.lastPoint = data.point;
|
|
513
|
-
// Mark as moved once total displacement crosses the slop
|
|
514
|
-
// threshold — used at pointerup to decide context-menu vs
|
|
515
|
-
// drag for right-click gestures.
|
|
516
|
-
if (!editor.panGesture.moved &&
|
|
517
|
-
vec2.distance(editor.panGesture.startPoint, data.point) > LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
518
|
-
editor.panGesture.moved = true;
|
|
519
|
-
}
|
|
520
|
-
// Natural-grab direction: cursor right → world moves right
|
|
521
|
-
// (shapes follow the finger). `viewportPanBy` already
|
|
522
|
-
// subtracts deltaScreen from pan, so we pass the raw cursor
|
|
523
|
-
// delta — no extra inversion.
|
|
524
|
-
editor.panBy({ x: dx, y: dy });
|
|
1177
|
+
if (handleMovePan(editor, ev, data))
|
|
525
1178
|
return;
|
|
526
|
-
|
|
527
|
-
// Update tracked pointer position. In pinch mode, recompute the
|
|
528
|
-
// gesture and short-circuit before sending to the machine.
|
|
529
|
-
if (editor.activePointers.has(ev.pointerId)) {
|
|
530
|
-
editor.activePointers.set(ev.pointerId, data.point);
|
|
531
|
-
}
|
|
532
|
-
if (editor.pinch.isActive()) {
|
|
533
|
-
editor.applyPinch();
|
|
1179
|
+
if (handleMovePinch(editor, ev, data))
|
|
534
1180
|
return;
|
|
535
|
-
|
|
536
|
-
// One-finger pan: an armed touch press on empty canvas that has now
|
|
537
|
-
// dragged past slop becomes a pan (not a marquee lasso). beginPanGesture
|
|
538
|
-
// POINTER_CANCELs the nascent machine gesture (clears the lasso preview).
|
|
539
|
-
if (editor.touchPanCandidate !== null &&
|
|
540
|
-
editor.activePointers.size === 1 &&
|
|
541
|
-
vec2.distance(editor.touchPanCandidate, data.point) > LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
542
|
-
const origin = editor.touchPanCandidate;
|
|
543
|
-
editor.touchPanCandidate = null;
|
|
544
|
-
editor.beginPanGesture(ev.pointerId, ev.button, origin);
|
|
545
|
-
const pan = editor.panGesture;
|
|
546
|
-
if (pan) {
|
|
547
|
-
pan.moved = true;
|
|
548
|
-
editor.panBy({ x: data.point.x - origin.x, y: data.point.y - origin.y });
|
|
549
|
-
pan.lastPoint = data.point;
|
|
550
|
-
}
|
|
1181
|
+
if (handleMoveOneFingerPan(editor, ev, data))
|
|
551
1182
|
return;
|
|
552
|
-
}
|
|
553
1183
|
// Cancel long-press timer if the finger has moved beyond slop.
|
|
554
1184
|
editor.longPress.cancelIfMovedBeyond(data.point, LONG_PRESS_MAX_MOVEMENT_PX);
|
|
555
1185
|
const worldPoint = editor.screenToWorld(data.point);
|
|
556
|
-
// Track cursor for paste-at-cursor and other commands
|
|
557
|
-
// sensible drop target.
|
|
1186
|
+
// Track cursor for paste-at-cursor and other drop-target-aware commands.
|
|
558
1187
|
editor.lastPointerWorld = worldPoint;
|
|
559
|
-
// Context cursor: recompute every move (before the gesture branches
|
|
1188
|
+
// Context cursor: recompute every move (before the gesture branches
|
|
560
1189
|
// early-return) so it reflects hover targets AND active gestures.
|
|
561
1190
|
editor.refreshCursor(worldPoint);
|
|
562
|
-
|
|
563
|
-
if (editor.isDraggingSegment) {
|
|
564
|
-
editor.updateSegmentDrag(worldPoint);
|
|
1191
|
+
if (handleMoveCrop(editor, worldPoint))
|
|
565
1192
|
return;
|
|
566
|
-
|
|
567
|
-
// Host-managed waypoint (bend-point) drag of the selected link.
|
|
568
|
-
if (editor.isDraggingWaypoint) {
|
|
569
|
-
editor.updateWaypointDrag(worldPoint);
|
|
1193
|
+
if (handleMoveSegmentDrag(editor, worldPoint))
|
|
570
1194
|
return;
|
|
571
|
-
|
|
572
|
-
// Link drag started from a start-anchor (select mode, no tool switch).
|
|
573
|
-
// Host-managed end-to-end — the machine never saw a POINTER_DOWN for
|
|
574
|
-
// it. Mirror the draw-edge tool: live link preview from the anchor +
|
|
575
|
-
// snap-target highlight on the shape under the cursor. Gated on the
|
|
576
|
-
// same drag threshold so a click that barely moves is not a draw.
|
|
577
|
-
if (editor.linkDragFromAnchor) {
|
|
578
|
-
const drag = editor.linkDragFromAnchor;
|
|
579
|
-
if (!drag.moved) {
|
|
580
|
-
const dx = worldPoint.x - drag.origin.x;
|
|
581
|
-
const dy = worldPoint.y - drag.origin.y;
|
|
582
|
-
if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD)
|
|
583
|
-
return;
|
|
584
|
-
drag.moved = true;
|
|
585
|
-
}
|
|
586
|
-
editor.applyLinkPreview(drag.fromElement, drag.fromWorld, worldPoint);
|
|
587
|
-
editor.updateHoveredLinkTarget(worldPoint);
|
|
1195
|
+
if (handleMoveWaypointDrag(editor, worldPoint))
|
|
588
1196
|
return;
|
|
589
|
-
|
|
590
|
-
// Drag-select inside the edited text shape.
|
|
591
|
-
if (editor.editingTextElement !== null && editor.isTextDragging) {
|
|
592
|
-
editor.extendTextSelectionToPoint(worldPoint);
|
|
1197
|
+
if (handleMoveLinkAnchorDrag(editor, worldPoint))
|
|
593
1198
|
return;
|
|
594
|
-
|
|
595
|
-
// Brush stroke in progress — append a vertex and skip everything
|
|
596
|
-
// else (machine, container hover, hovered-edge target).
|
|
597
|
-
if (editor.brushStroke) {
|
|
598
|
-
editor.extendBrushStroke(worldPoint, ev.pressure);
|
|
1199
|
+
if (handleMoveTextDragSelect(editor, worldPoint))
|
|
599
1200
|
return;
|
|
600
|
-
|
|
601
|
-
// Container drop preview: while dragging a single shape, find the
|
|
602
|
-
// topmost container under cursor (excluding the dragged shape and
|
|
603
|
-
// its descendants) and stash the drop-zone for the overlay.
|
|
604
|
-
if (editor.dragElementId) {
|
|
605
|
-
const dragged = editor.dragElementId;
|
|
606
|
-
const exclude = new Set([dragged]);
|
|
607
|
-
// Don't drop a container onto itself or into one of its own
|
|
608
|
-
// descendants (would create a cycle).
|
|
609
|
-
for (const s of editor._scene.elements.values()) {
|
|
610
|
-
let cursor = s.parentId;
|
|
611
|
-
for (let i = 0; cursor && i < 64; i++) {
|
|
612
|
-
if (cursor === dragged) {
|
|
613
|
-
exclude.add(s.id);
|
|
614
|
-
break;
|
|
615
|
-
}
|
|
616
|
-
cursor = editor._scene.elements.get(cursor)?.parentId;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
const container = findContainerAt(editor._scene, worldPoint, exclude);
|
|
620
|
-
if (container) {
|
|
621
|
-
const zone = getDropZoneWorld(container);
|
|
622
|
-
if (zone) {
|
|
623
|
-
const next = { id: container.id, dropZone: zone };
|
|
624
|
-
if (editor.containerHover?.id !== next.id ||
|
|
625
|
-
editor.containerHover.dropZone !== next.dropZone) {
|
|
626
|
-
editor.containerHover = next;
|
|
627
|
-
editor.notify();
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
else if (editor.containerHover !== null) {
|
|
632
|
-
editor.containerHover = null;
|
|
633
|
-
editor.notify();
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
// Annotation drag — update annotation position from delta. No
|
|
637
|
-
// patches per-move; commit on pointerup so undo is one step.
|
|
638
|
-
if (editor.annotationDrag) {
|
|
639
|
-
const drag = editor.annotationDrag;
|
|
640
|
-
const dx = worldPoint.x - drag.originWorldPoint.x;
|
|
641
|
-
const dy = worldPoint.y - drag.originWorldPoint.y;
|
|
642
|
-
if (dx !== 0 || dy !== 0)
|
|
643
|
-
drag.moved = true;
|
|
644
|
-
const ann = editor._scene.annotations.get(drag.id);
|
|
645
|
-
if (ann) {
|
|
646
|
-
// Mutate via apply to keep render in sync; final commit on
|
|
647
|
-
// up rewrites the patch from origin to final.
|
|
648
|
-
const newPos = { x: drag.originPosition.x + dx, y: drag.originPosition.y + dy };
|
|
649
|
-
const next = { ...ann, position: newPos };
|
|
650
|
-
const annotations = new Map(editor._scene.annotations);
|
|
651
|
-
annotations.set(drag.id, next);
|
|
652
|
-
editor._scene = { ...editor._scene, annotations };
|
|
653
|
-
editor.notify();
|
|
654
|
-
}
|
|
1201
|
+
if (handleMoveBrush(editor, worldPoint, ev.pressure))
|
|
655
1202
|
return;
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
ctx.mode !== "select" &&
|
|
665
|
-
ctx.mode !== "draw-edge" &&
|
|
666
|
-
editor.isDrawingPhase(ctx)) {
|
|
667
|
-
// Update rubber-band preview live for rect / ellipse drawing.
|
|
668
|
-
editor.drawingPreview = boundsFromPoints(ctx.pressOrigin, worldPoint);
|
|
669
|
-
}
|
|
670
|
-
// Port-overlay tracking in draw-edge mode — both when idle (showing
|
|
671
|
-
// where you can start an edge) and during the gesture (showing the
|
|
672
|
-
// snap target as the pointer crosses shapes).
|
|
673
|
-
if (ctx.mode === "draw-edge") {
|
|
674
|
-
editor.updateHoveredLinkTarget(worldPoint);
|
|
675
|
-
}
|
|
676
|
-
else if (editor.hoveredLinkTarget !== null) {
|
|
677
|
-
editor.hoveredLinkTarget = null;
|
|
678
|
-
editor.notify();
|
|
679
|
-
}
|
|
680
|
-
// Hover-to-play: while idle (no active press) and directly over an
|
|
681
|
-
// animated image, signal hover so a paused GIF can resume. (No
|
|
682
|
-
// link-attach anchor reveal on idle hover — those appear only while
|
|
683
|
-
// a link is actually being drawn; see the draw-edge / drag-from-
|
|
684
|
-
// anchor paths.)
|
|
685
|
-
if (!ctx.pressOrigin) {
|
|
686
|
-
const hov = editor.hitTest(worldPoint);
|
|
687
|
-
const directHs = hov.kind === "element" ? editor._scene.elements.get(hov.id) : undefined;
|
|
688
|
-
editor.hoverAnimatedElement(directHs && isImage(directHs) && directHs.animationKind ? directHs.id : null);
|
|
689
|
-
// Track the idle cursor so the SINGLE selected element's link-start dot
|
|
690
|
-
// grows by proximity (`worldPoint`). Only the selected element's dots
|
|
691
|
-
// react; connecting from an unselected shape on hover is not offered.
|
|
692
|
-
editor.setHoverCursorWorld(editor.mode === "select" ? worldPoint : null);
|
|
693
|
-
}
|
|
694
|
-
editor.actor.send({ type: "POINTER_MOVE", point: worldPoint });
|
|
1203
|
+
if (handleMoveErase(editor, worldPoint, data.modifiers.alt))
|
|
1204
|
+
return;
|
|
1205
|
+
if (handleMoveLaser(editor, worldPoint))
|
|
1206
|
+
return;
|
|
1207
|
+
updateContainerDropPreview(editor, worldPoint);
|
|
1208
|
+
if (handleMoveAnnotationDrag(editor, worldPoint))
|
|
1209
|
+
return;
|
|
1210
|
+
dispatchMoveToMachine(editor, worldPoint);
|
|
695
1211
|
};
|
|
696
1212
|
const onUp = (ev) => {
|
|
697
1213
|
if (editor.host.hasPointerCapture(ev.pointerId)) {
|
|
698
1214
|
editor.host.releasePointerCapture(ev.pointerId);
|
|
699
1215
|
}
|
|
700
1216
|
editor.activePointers.delete(ev.pointerId);
|
|
701
|
-
// Tap or drag finished — disarm the one-finger-pan candidate
|
|
702
|
-
// never promoted to a pan, so it falls through to select/deselect).
|
|
1217
|
+
// Tap or drag finished — disarm the one-finger-pan candidate.
|
|
703
1218
|
editor.touchPanCandidate = null;
|
|
704
|
-
|
|
705
|
-
if (editor.panGesture?.pointerId === ev.pointerId) {
|
|
706
|
-
editor.endPanGesture();
|
|
1219
|
+
if (handleUpPan(editor, ev))
|
|
707
1220
|
return;
|
|
708
|
-
|
|
709
|
-
// Exit pinch when the second-to-last finger lifts — the surviving
|
|
710
|
-
// touch (if any) does NOT resume as a single-finger drag, because
|
|
711
|
-
// we already cancelled the machine on pinch entry.
|
|
712
|
-
if (editor.pinch.isActive()) {
|
|
713
|
-
if (editor.activePointers.size < 2)
|
|
714
|
-
editor.pinch.end();
|
|
1221
|
+
if (handleUpPinch(editor))
|
|
715
1222
|
return;
|
|
716
|
-
}
|
|
717
1223
|
// Long-press loses its chance the moment the user releases.
|
|
718
1224
|
editor.cancelLongPress();
|
|
719
|
-
|
|
720
|
-
if (editor.isDraggingSegment) {
|
|
721
|
-
editor.endSegmentDrag();
|
|
1225
|
+
if (handleUpCrop(editor))
|
|
722
1226
|
return;
|
|
723
|
-
|
|
724
|
-
// Commit a host-managed waypoint drag (one undo step; collapses if
|
|
725
|
-
// dropped onto the line).
|
|
726
|
-
if (editor.isDraggingWaypoint) {
|
|
727
|
-
editor.endWaypointDrag();
|
|
1227
|
+
if (handleUpSegmentDrag(editor))
|
|
728
1228
|
return;
|
|
729
|
-
|
|
730
|
-
// Commit a link drag that began from a start-anchor. If it moved past
|
|
731
|
-
// the threshold, create the edge (landing on the shape under the
|
|
732
|
-
// cursor, if any). If it did NOT move it was a plain click that merely
|
|
733
|
-
// landed in the anchor grab halo — fall back to normal click semantics
|
|
734
|
-
// so the gesture still selects / deselects. Either way clear the
|
|
735
|
-
// preview/hover.
|
|
736
|
-
//
|
|
737
|
-
// A click *exactly on a dot* (narrow radius) spawns a new element + link
|
|
738
|
-
// in that dot's direction; a click in the wider grab halo hit-tests as
|
|
739
|
-
// empty canvas → deselect.
|
|
740
|
-
if (editor.linkDragFromAnchor) {
|
|
741
|
-
const drag = editor.linkDragFromAnchor;
|
|
742
|
-
editor.linkDragFromAnchor = null;
|
|
743
|
-
const upData = fromPointerEvent(ev, editor.host);
|
|
744
|
-
const upWorld = editor.screenToWorld(upData.point);
|
|
745
|
-
if (drag.moved) {
|
|
746
|
-
const upHit = editor.hitTest(upWorld);
|
|
747
|
-
const toElement = upHit.kind === "element" ? upHit.id : null;
|
|
748
|
-
editor.applyEmit({
|
|
749
|
-
type: "CREATE_EDGE",
|
|
750
|
-
fromElement: drag.fromElement,
|
|
751
|
-
toElement,
|
|
752
|
-
fromPoint: drag.fromWorld,
|
|
753
|
-
toPoint: upWorld,
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
else {
|
|
757
|
-
// A click, not a draw. If it landed exactly ON the dot (narrow
|
|
758
|
-
// radius), spawn a new element + link in that dot's direction.
|
|
759
|
-
// Otherwise it was a click in the wider grab halo → normal
|
|
760
|
-
// select / deselect by hit-test.
|
|
761
|
-
const selShape = getElement(editor._scene, drag.fromElement);
|
|
762
|
-
const zoom = editor._scene.viewport.zoom || 1;
|
|
763
|
-
let onDot = false;
|
|
764
|
-
if (selShape) {
|
|
765
|
-
const { names, worldPoints } = anchorOverlayPoints(selShape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
766
|
-
const idx = names.indexOf(drag.anchorName);
|
|
767
|
-
if (idx >= 0) {
|
|
768
|
-
const dp = req(worldPoints[idx]);
|
|
769
|
-
const r = editor.anchorClickRadius / zoom;
|
|
770
|
-
const dx = dp.x - drag.origin.x;
|
|
771
|
-
const dy = dp.y - drag.origin.y;
|
|
772
|
-
onDot = dx * dx + dy * dy <= r * r;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (onDot) {
|
|
776
|
-
editor.createLinkedElementFromAnchor(drag.fromElement, drag.anchorName);
|
|
777
|
-
}
|
|
778
|
-
else {
|
|
779
|
-
const upHit = editor.hitTest(upWorld);
|
|
780
|
-
if (upHit.kind === "empty")
|
|
781
|
-
editor.applyEmit({ type: "SELECT_CLEAR" });
|
|
782
|
-
else if (upHit.kind === "element")
|
|
783
|
-
editor.applyEmit({ type: "SELECT_REPLACE", id: upHit.id });
|
|
784
|
-
else if (upHit.kind === "link")
|
|
785
|
-
editor.applyEmit({ type: "SELECT_EDGE_REPLACE", id: upHit.id });
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
editor.edgePreview = null;
|
|
789
|
-
editor.hoveredLinkTarget = null;
|
|
790
|
-
editor.notify();
|
|
1229
|
+
if (handleUpWaypointDrag(editor))
|
|
791
1230
|
return;
|
|
792
|
-
|
|
793
|
-
// End an in-canvas text drag-select.
|
|
794
|
-
if (editor.editingTextElement !== null && editor.isTextDragging) {
|
|
795
|
-
editor.endTextDragSelect();
|
|
1231
|
+
if (handleUpLinkAnchorDrag(editor, ev))
|
|
796
1232
|
return;
|
|
797
|
-
|
|
798
|
-
// Commit brush stroke if one is in progress.
|
|
799
|
-
if (editor.brushStroke) {
|
|
800
|
-
editor.commitBrushStroke();
|
|
1233
|
+
if (handleUpTextDragSelect(editor))
|
|
801
1234
|
return;
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const final = editor._scene.annotations.get(drag.id);
|
|
810
|
-
if (final) {
|
|
811
|
-
// Reset to origin, then issue patch with proper before/after.
|
|
812
|
-
const origin = { ...final, position: drag.originPosition };
|
|
813
|
-
const annotations = new Map(editor._scene.annotations);
|
|
814
|
-
annotations.set(drag.id, origin);
|
|
815
|
-
editor._scene = { ...editor._scene, annotations };
|
|
816
|
-
const r = updateAnnotation(editor._scene, drag.id, () => final);
|
|
817
|
-
editor._scene = r.scene;
|
|
818
|
-
editor._history.push(r.patch);
|
|
819
|
-
editor.notify();
|
|
820
|
-
}
|
|
821
|
-
}
|
|
1235
|
+
if (handleUpBrush(editor))
|
|
1236
|
+
return;
|
|
1237
|
+
if (handleUpErase(editor))
|
|
1238
|
+
return;
|
|
1239
|
+
if (handleUpLaser(editor))
|
|
1240
|
+
return;
|
|
1241
|
+
if (handleUpAnnotationDrag(editor))
|
|
822
1242
|
return;
|
|
823
|
-
}
|
|
824
1243
|
const data = fromPointerEvent(ev, editor.host);
|
|
825
1244
|
const worldPoint = editor.screenToWorld(data.point);
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
{
|
|
830
|
-
const origin = editor.actor.getSnapshot().context.pressOrigin;
|
|
831
|
-
const linkMod = data.modifiers.meta || data.modifiers.ctrl;
|
|
832
|
-
if (origin && linkMod) {
|
|
833
|
-
const zoom = editor._scene.viewport.zoom || 1;
|
|
834
|
-
const movedPx = Math.hypot(worldPoint.x - origin.x, worldPoint.y - origin.y) * zoom;
|
|
835
|
-
if (movedPx < LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
836
|
-
const hit = editor.hitTest(worldPoint);
|
|
837
|
-
if (hit.kind === "element") {
|
|
838
|
-
const href = editor.elementLink(hit.id);
|
|
839
|
-
if (href) {
|
|
840
|
-
editor.openLink(href);
|
|
841
|
-
editor.actor.send({ type: "POINTER_UP", point: worldPoint });
|
|
842
|
-
editor.commitGesture();
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
// First, fire any click-style effect derived from the press context.
|
|
850
|
-
const ctxBeforeUp = editor.actor.getSnapshot().context;
|
|
851
|
-
let clickEffect = interpretPressEnd(ctxBeforeUp, worldPoint);
|
|
852
|
-
// A shift/meta TAP on a shape the press already added additively must
|
|
853
|
-
// NOT toggle it back off — the press handled the add, this would undo
|
|
854
|
-
// it (net zero). Drop the redundant toggle. (shift-tap on a shape that
|
|
855
|
-
// was already selected → additivePressAdded is null → toggle still
|
|
856
|
-
// fires and removes it, as expected.)
|
|
857
|
-
if (clickEffect?.type === "SELECT_TOGGLE" && editor.additivePressAdded === clickEffect.id) {
|
|
858
|
-
clickEffect = null;
|
|
859
|
-
}
|
|
860
|
-
editor.additivePressAdded = null;
|
|
861
|
-
// Group isolation routing:
|
|
862
|
-
// - Double-click on a grouped shape → enter the topmost group
|
|
863
|
-
// ancestor; select the inner shape directly (skipping the
|
|
864
|
-
// promote-to-group logic that would otherwise re-select the
|
|
865
|
-
// group root).
|
|
866
|
-
// - Inside isolation, a click that lands outside the entered
|
|
867
|
-
// group's descendants (empty space OR another shape) exits
|
|
868
|
-
// isolation and lets the normal selection happen.
|
|
869
|
-
// Both branches override what `interpretPressEnd` produced.
|
|
870
|
-
const handledByIsolation = editor.routeIsolationClick(clickEffect, worldPoint);
|
|
871
|
-
if (!handledByIsolation && clickEffect) {
|
|
872
|
-
editor.applyEmit(clickEffect);
|
|
873
|
-
}
|
|
874
|
-
editor.drawingPreview = null;
|
|
875
|
-
// Provide the up-side hit-test when the gesture cares about where
|
|
876
|
-
// it lands: drawing a new edge, or re-binding an existing edge
|
|
877
|
-
// endpoint. The hit-test sees the *current* selection (edge or
|
|
878
|
-
// shape) and so resolves correctly to either kind.
|
|
879
|
-
// For drawing a NEW edge, the normal hit-test resolves the drop (no link is
|
|
880
|
-
// selected, so it falls through to the element). For re-binding an EXISTING
|
|
881
|
-
// endpoint, the dragged end now lives at the cursor and `hitTest` would
|
|
882
|
-
// return that endpoint handle instead of the element under it — use the
|
|
883
|
-
// element-only attach hit-test so the drop binds to the shape / anchor.
|
|
884
|
-
let upTarget;
|
|
885
|
-
if (ctxBeforeUp.mode === "draw-edge") {
|
|
886
|
-
upTarget = editor.hitTest(worldPoint);
|
|
887
|
-
}
|
|
888
|
-
else if (ctxBeforeUp.pressTarget?.kind === "edge-endpoint") {
|
|
889
|
-
upTarget = editor.linkAttachTargetAt(worldPoint);
|
|
890
|
-
}
|
|
891
|
-
editor.actor.send(upTarget !== undefined
|
|
892
|
-
? { type: "POINTER_UP", point: worldPoint, target: upTarget }
|
|
893
|
-
: { type: "POINTER_UP", point: worldPoint });
|
|
894
|
-
// Container reparent / drag-out — must run before commitGesture
|
|
895
|
-
// so the parentId / autoGrow patches land in the same undo step.
|
|
896
|
-
editor.applyContainerDrop(worldPoint);
|
|
897
|
-
// Frame membership re-evaluation (drop into / out of a frame, or a
|
|
898
|
-
// frame resized over shapes) — also before commit so it's one undo.
|
|
899
|
-
editor.reconcileFrameMembership();
|
|
900
|
-
editor.commitGesture();
|
|
901
|
-
// A tap (not a drag) on an animated image toggles its GIF playback —
|
|
902
|
-
// the way to resume a heavy GIF that auto-stopped or a GIF held paused
|
|
903
|
-
// under prefers-reduced-motion. Gated on near-zero displacement so it
|
|
904
|
-
// never fires at the end of a drag.
|
|
905
|
-
const origin = ctxBeforeUp.pressOrigin;
|
|
906
|
-
if (origin) {
|
|
907
|
-
const zoom = editor._scene.viewport.zoom || 1;
|
|
908
|
-
const movedPx = Math.hypot(worldPoint.x - origin.x, worldPoint.y - origin.y) * zoom;
|
|
909
|
-
if (movedPx < LONG_PRESS_MAX_MOVEMENT_PX) {
|
|
910
|
-
const hit = editor.hitTest(worldPoint);
|
|
911
|
-
if (hit.kind === "element") {
|
|
912
|
-
const s = editor._scene.elements.get(hit.id);
|
|
913
|
-
if (s && isImage(s) && s.animationKind)
|
|
914
|
-
editor.togglePlayback(s.id);
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
// The gesture is over — recompute the cursor for the now-idle hover state.
|
|
919
|
-
editor.refreshCursor(worldPoint);
|
|
1245
|
+
if (handleUpLinkOpen(editor, data, worldPoint))
|
|
1246
|
+
return;
|
|
1247
|
+
handleUpCommit(editor, worldPoint);
|
|
920
1248
|
};
|
|
921
1249
|
const onCancel = (ev) => {
|
|
922
1250
|
editor.activePointers.delete(ev.pointerId);
|
|
@@ -931,8 +1259,7 @@ export const bindPointerEvents = (editor) => {
|
|
|
931
1259
|
return;
|
|
932
1260
|
}
|
|
933
1261
|
editor.cancelLongPress();
|
|
934
|
-
// Abort a link-from-anchor drag — drop the preview/hover, create
|
|
935
|
-
// nothing.
|
|
1262
|
+
// Abort a link-from-anchor drag — drop the preview/hover, create nothing.
|
|
936
1263
|
if (editor.linkDragFromAnchor) {
|
|
937
1264
|
editor.linkDragFromAnchor = null;
|
|
938
1265
|
editor.edgePreview = null;
|
|
@@ -944,6 +1271,14 @@ export const bindPointerEvents = (editor) => {
|
|
|
944
1271
|
editor.cancelBrushStroke();
|
|
945
1272
|
return;
|
|
946
1273
|
}
|
|
1274
|
+
if (editor.eraseStroke) {
|
|
1275
|
+
editor.cancelEraseStroke();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (editor.laserDrawing) {
|
|
1279
|
+
editor.endLaserStroke();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
947
1282
|
// Annotation drag — revert to origin on cancel.
|
|
948
1283
|
if (editor.annotationDrag) {
|
|
949
1284
|
const drag = editor.annotationDrag;
|
|
@@ -965,18 +1300,12 @@ export const bindPointerEvents = (editor) => {
|
|
|
965
1300
|
editor.host.addEventListener("pointermove", onMove);
|
|
966
1301
|
editor.host.addEventListener("pointerup", onUp);
|
|
967
1302
|
editor.host.addEventListener("pointercancel", onCancel);
|
|
968
|
-
// Right-click handling: the contextmenu DOM event fires once per
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// the user was actually panning.
|
|
975
|
-
// The "menu on click without drag" path lives in `endPanGesture`:
|
|
976
|
-
// it fires `longPressListeners` directly, which is what
|
|
977
|
-
// ContextMenu also subscribes to. So a clean right-click still
|
|
978
|
-
// produces a menu — through our event channel, not the native
|
|
979
|
-
// contextmenu DOM event.
|
|
1303
|
+
// Right-click handling: the contextmenu DOM event fires once per right mouse
|
|
1304
|
+
// press, AFTER pointerup on most browsers. We use `suppressNextContextMenu`
|
|
1305
|
+
// (set on right-click pointerdown) to preventDefault the native menu and
|
|
1306
|
+
// stopPropagation so window-level listeners (like `@react-ui/ContextMenu`)
|
|
1307
|
+
// don't re-open a menu when the user was actually panning. The "menu on click
|
|
1308
|
+
// without drag" path lives in `endPanGesture`.
|
|
980
1309
|
const onContextMenu = (ev) => {
|
|
981
1310
|
if (!editor.suppressNextContextMenu)
|
|
982
1311
|
return;
|
|
@@ -984,12 +1313,10 @@ export const bindPointerEvents = (editor) => {
|
|
|
984
1313
|
ev.preventDefault();
|
|
985
1314
|
ev.stopPropagation();
|
|
986
1315
|
};
|
|
987
|
-
// Capture phase so we beat the window-level listener that
|
|
988
|
-
// ContextMenu attaches in its useEffect.
|
|
1316
|
+
// Capture phase so we beat the window-level listener that ContextMenu attaches.
|
|
989
1317
|
editor.host.addEventListener("contextmenu", onContextMenu, true);
|
|
990
|
-
// Window-level Space tracking so Space anywhere on the page
|
|
991
|
-
//
|
|
992
|
-
// text input — Space should still type a space there.
|
|
1318
|
+
// Window-level Space tracking so Space anywhere on the page arms the next
|
|
1319
|
+
// mouse drag as a pan. Skip when focus is in a text input.
|
|
993
1320
|
const onKeyDown = (ev) => {
|
|
994
1321
|
if (ev.code !== "Space" && ev.key !== " ")
|
|
995
1322
|
return;
|
|
@@ -1000,8 +1327,7 @@ export const bindPointerEvents = (editor) => {
|
|
|
1000
1327
|
editor.spaceHeld = true;
|
|
1001
1328
|
// Visual affordance: "grab" cursor signals the user can drag-pan.
|
|
1002
1329
|
editor.refreshCursor();
|
|
1003
|
-
// Prevent page scroll on Space —
|
|
1004
|
-
// input is focused. We're holding it as a modifier, not as text.
|
|
1330
|
+
// Prevent page scroll on Space — we're holding it as a modifier, not text.
|
|
1005
1331
|
ev.preventDefault();
|
|
1006
1332
|
};
|
|
1007
1333
|
const onKeyUp = (ev) => {
|
|
@@ -1018,75 +1344,33 @@ export const bindPointerEvents = (editor) => {
|
|
|
1018
1344
|
window.addEventListener("keydown", onKeyDown);
|
|
1019
1345
|
window.addEventListener("keyup", onKeyUp);
|
|
1020
1346
|
}
|
|
1021
|
-
// Wheel routing: mouse wheel → zoom, trackpad → pan / pinch.
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
// Per-event classification:
|
|
1026
|
-
// • Cmd / Ctrl + wheel (also browser-synthesized for trackpad
|
|
1027
|
-
// pinch) → ZOOM around cursor.
|
|
1028
|
-
// • Shift + plain wheel → horizontal pan from vertical delta.
|
|
1347
|
+
// Wheel routing: mouse wheel → zoom, trackpad → pan / pinch. Browsers fire
|
|
1348
|
+
// identical `wheel` events for both devices and no per-event signal reliably
|
|
1349
|
+
// distinguishes them. Per-event classification:
|
|
1350
|
+
// • Cmd / Ctrl + wheel (also browser-synthesized for trackpad pinch) → ZOOM.
|
|
1029
1351
|
// • Any deltaX ≠ 0 → trackpad 2D swipe → PAN both axes.
|
|
1030
|
-
// • Plain deltaY only → ZOOM (mouse wheel; rare pure-vertical
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
// Pan direction: `panBy` subtracts deltaScreen from `viewport.pan`,
|
|
1034
|
-
// so we negate the wheel delta — positive deltaX (page scrolls
|
|
1035
|
-
// right) → camera right → content shifts LEFT, matching native
|
|
1036
|
-
// browser scroll feel.
|
|
1352
|
+
// • Plain deltaY only → ZOOM (mouse wheel; rare pure-vertical trackpad
|
|
1353
|
+
// swipes also land here).
|
|
1037
1354
|
const onWheel = (ev) => {
|
|
1038
1355
|
ev.preventDefault();
|
|
1039
1356
|
const rect = editor.host.getBoundingClientRect();
|
|
1040
1357
|
const screenPoint = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
|
|
1041
|
-
|
|
1042
|
-
if (ev.deltaY === 0)
|
|
1043
|
-
return;
|
|
1044
|
-
// Clamp the raw wheel `deltaY` to MAX_STEP so the harsh ratchet
|
|
1045
|
-
// of a mouse wheel turns into a calm ~10 % step per notch, while
|
|
1046
|
-
// trackpad pinch events — which arrive with `|deltaY|` of 2–5 —
|
|
1047
|
-
// bypass the clamp and stay granular for smooth multi-frame zooms.
|
|
1048
|
-
const clampedDelta = Math.abs(ev.deltaY) > WHEEL_ZOOM_MAX_STEP
|
|
1049
|
-
? WHEEL_ZOOM_MAX_STEP * Math.sign(ev.deltaY)
|
|
1050
|
-
: ev.deltaY;
|
|
1051
|
-
const factor = 1 - (clampedDelta * WHEEL_ZOOM_SPEED) / 100;
|
|
1052
|
-
if (factor <= 0)
|
|
1053
|
-
return;
|
|
1054
|
-
const currentZoom = editor._scene.viewport.zoom;
|
|
1055
|
-
const nextZoom = clampZoom(currentZoom * factor);
|
|
1056
|
-
if (nextZoom === currentZoom)
|
|
1057
|
-
return;
|
|
1058
|
-
const anchor = editor.screenToWorld(screenPoint);
|
|
1059
|
-
editor.zoomAt(nextZoom / currentZoom, anchor);
|
|
1060
|
-
};
|
|
1061
|
-
const applyPan = () => {
|
|
1062
|
-
let dx = ev.deltaX;
|
|
1063
|
-
let dy = ev.deltaY;
|
|
1064
|
-
if (ev.shiftKey && dx === 0) {
|
|
1065
|
-
dx = dy;
|
|
1066
|
-
dy = 0;
|
|
1067
|
-
}
|
|
1068
|
-
editor.panBy({ x: -dx * WHEEL_PAN_FACTOR, y: -dy * WHEEL_PAN_FACTOR });
|
|
1069
|
-
};
|
|
1070
|
-
// Modifier-driven zoom (Cmd/Ctrl+wheel + trackpad pinch via
|
|
1071
|
-
// browser-synthesized ctrlKey).
|
|
1358
|
+
// Modifier-driven zoom (Cmd/Ctrl+wheel + trackpad pinch via ctrlKey).
|
|
1072
1359
|
if (ev.ctrlKey || ev.metaKey) {
|
|
1073
|
-
|
|
1360
|
+
applyWheelZoom(editor, ev, screenPoint);
|
|
1074
1361
|
return;
|
|
1075
1362
|
}
|
|
1076
|
-
// Trackpad 2-finger swipe with any horizontal component →
|
|
1077
|
-
//
|
|
1078
|
-
// branch never misroutes mouse input.
|
|
1363
|
+
// Trackpad 2-finger swipe with any horizontal component → pan both axes.
|
|
1364
|
+
// Mouse wheels never set deltaX, so this branch never misroutes mouse input.
|
|
1079
1365
|
if (ev.deltaX !== 0) {
|
|
1080
|
-
|
|
1366
|
+
applyWheelPan(editor, ev);
|
|
1081
1367
|
return;
|
|
1082
1368
|
}
|
|
1083
|
-
// Plain vertical wheel — always ZOOM.
|
|
1084
|
-
|
|
1085
|
-
// Space+drag or right-drag.
|
|
1086
|
-
applyZoom();
|
|
1369
|
+
// Plain vertical wheel — always ZOOM.
|
|
1370
|
+
applyWheelZoom(editor, ev, screenPoint);
|
|
1087
1371
|
};
|
|
1088
|
-
// `passive: false` because we preventDefault. Browsers default wheel
|
|
1089
|
-
//
|
|
1372
|
+
// `passive: false` because we preventDefault. Browsers default wheel listeners
|
|
1373
|
+
// to passive — must opt out explicitly.
|
|
1090
1374
|
editor.host.addEventListener("wheel", onWheel, { passive: false });
|
|
1091
1375
|
return () => {
|
|
1092
1376
|
editor.host.removeEventListener("pointerdown", onDown);
|