@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.
Files changed (149) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +2 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/actions/actionArrange.js +4 -4
  5. package/dist/actions/actionArrange.js.map +1 -1
  6. package/dist/actions/actionClipboard.d.ts.map +1 -1
  7. package/dist/actions/actionClipboard.js +5 -0
  8. package/dist/actions/actionClipboard.js.map +1 -1
  9. package/dist/actions/actionKeyboard.d.ts.map +1 -1
  10. package/dist/actions/actionKeyboard.js +77 -9
  11. package/dist/actions/actionKeyboard.js.map +1 -1
  12. package/dist/actions/actionMode.d.ts +4 -0
  13. package/dist/actions/actionMode.d.ts.map +1 -1
  14. package/dist/actions/actionMode.js +54 -0
  15. package/dist/actions/actionMode.js.map +1 -1
  16. package/dist/actions/actionSelection.d.ts.map +1 -1
  17. package/dist/actions/actionSelection.js +1 -0
  18. package/dist/actions/actionSelection.js.map +1 -1
  19. package/dist/actions/actionView.d.ts +6 -0
  20. package/dist/actions/actionView.d.ts.map +1 -1
  21. package/dist/actions/actionView.js +19 -1
  22. package/dist/actions/actionView.js.map +1 -1
  23. package/dist/actions/actionZoom.d.ts.map +1 -1
  24. package/dist/actions/actionZoom.js +5 -0
  25. package/dist/actions/actionZoom.js.map +1 -1
  26. package/dist/actions/index.d.ts +3 -3
  27. package/dist/actions/index.d.ts.map +1 -1
  28. package/dist/actions/index.js +3 -3
  29. package/dist/actions/index.js.map +1 -1
  30. package/dist/actions/registry.d.ts.map +1 -1
  31. package/dist/actions/registry.js +13 -0
  32. package/dist/actions/registry.js.map +1 -1
  33. package/dist/actions/types.d.ts +9 -0
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js.map +1 -1
  36. package/dist/clipboard.d.ts +0 -14
  37. package/dist/clipboard.d.ts.map +1 -1
  38. package/dist/clipboard.js +1 -1
  39. package/dist/clipboard.js.map +1 -1
  40. package/dist/constants.d.ts +160 -18
  41. package/dist/constants.d.ts.map +1 -1
  42. package/dist/constants.js +162 -2
  43. package/dist/constants.js.map +1 -1
  44. package/dist/dom-events.d.ts +1 -2
  45. package/dist/dom-events.d.ts.map +1 -1
  46. package/dist/dom-events.js.map +1 -1
  47. package/dist/editor/animation-scene.d.ts +15 -0
  48. package/dist/editor/animation-scene.d.ts.map +1 -1
  49. package/dist/editor/animation-scene.js +123 -0
  50. package/dist/editor/animation-scene.js.map +1 -1
  51. package/dist/editor/applies/create.d.ts +0 -6
  52. package/dist/editor/applies/create.d.ts.map +1 -1
  53. package/dist/editor/applies/create.js +1 -1
  54. package/dist/editor/applies/create.js.map +1 -1
  55. package/dist/editor/applies/link-move.d.ts +0 -8
  56. package/dist/editor/applies/link-move.d.ts.map +1 -1
  57. package/dist/editor/applies/link-move.js +1 -1
  58. package/dist/editor/applies/link-move.js.map +1 -1
  59. package/dist/editor/interaction-state.d.ts +184 -0
  60. package/dist/editor/interaction-state.d.ts.map +1 -0
  61. package/dist/editor/interaction-state.js +139 -0
  62. package/dist/editor/interaction-state.js.map +1 -0
  63. package/dist/editor/link-handle-drag.d.ts +152 -0
  64. package/dist/editor/link-handle-drag.d.ts.map +1 -0
  65. package/dist/editor/link-handle-drag.js +300 -0
  66. package/dist/editor/link-handle-drag.js.map +1 -0
  67. package/dist/editor/pointer-binding.d.ts +9 -11
  68. package/dist/editor/pointer-binding.d.ts.map +1 -1
  69. package/dist/editor/pointer-binding.js +1185 -901
  70. package/dist/editor/pointer-binding.js.map +1 -1
  71. package/dist/editor/public/brush.d.ts +31 -12
  72. package/dist/editor/public/brush.d.ts.map +1 -1
  73. package/dist/editor/public/brush.js +66 -17
  74. package/dist/editor/public/brush.js.map +1 -1
  75. package/dist/editor/public/cursor.d.ts +1 -15
  76. package/dist/editor/public/cursor.d.ts.map +1 -1
  77. package/dist/editor/public/cursor.js +14 -3
  78. package/dist/editor/public/cursor.js.map +1 -1
  79. package/dist/editor/public/eraser.d.ts +55 -0
  80. package/dist/editor/public/eraser.d.ts.map +1 -0
  81. package/dist/editor/public/eraser.js +65 -0
  82. package/dist/editor/public/eraser.js.map +1 -0
  83. package/dist/editor/public/laser.d.ts +38 -0
  84. package/dist/editor/public/laser.d.ts.map +1 -0
  85. package/dist/editor/public/laser.js +42 -0
  86. package/dist/editor/public/laser.js.map +1 -0
  87. package/dist/editor/public/placement.d.ts.map +1 -1
  88. package/dist/editor/public/placement.js +9 -1
  89. package/dist/editor/public/placement.js.map +1 -1
  90. package/dist/editor/public/selection-ops.d.ts +12 -0
  91. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  92. package/dist/editor/public/selection-ops.js +19 -1
  93. package/dist/editor/public/selection-ops.js.map +1 -1
  94. package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
  95. package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
  96. package/dist/editor/public/stroke-eraser-coverage.js +168 -0
  97. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
  98. package/dist/editor/public/stroke-eraser.d.ts +50 -0
  99. package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
  100. package/dist/editor/public/stroke-eraser.js +176 -0
  101. package/dist/editor/public/stroke-eraser.js.map +1 -0
  102. package/dist/editor/public/stroke-smoothing.d.ts +21 -0
  103. package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
  104. package/dist/editor/public/stroke-smoothing.js +37 -0
  105. package/dist/editor/public/stroke-smoothing.js.map +1 -0
  106. package/dist/editor/public/tool-ops.d.ts +130 -0
  107. package/dist/editor/public/tool-ops.d.ts.map +1 -0
  108. package/dist/editor/public/tool-ops.js +392 -0
  109. package/dist/editor/public/tool-ops.js.map +1 -0
  110. package/dist/editor/public/z-order.d.ts +1 -12
  111. package/dist/editor/public/z-order.d.ts.map +1 -1
  112. package/dist/editor/public/z-order.js +1 -1
  113. package/dist/editor/public/z-order.js.map +1 -1
  114. package/dist/editor/public/zoom-pan.d.ts +9 -0
  115. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  116. package/dist/editor/public/zoom-pan.js +31 -0
  117. package/dist/editor/public/zoom-pan.js.map +1 -1
  118. package/dist/editor/render-orchestrator.d.ts +177 -5
  119. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  120. package/dist/editor/render-orchestrator.js +517 -351
  121. package/dist/editor/render-orchestrator.js.map +1 -1
  122. package/dist/editor/text-edit.d.ts +136 -0
  123. package/dist/editor/text-edit.d.ts.map +1 -0
  124. package/dist/editor/text-edit.js +328 -0
  125. package/dist/editor/text-edit.js.map +1 -0
  126. package/dist/editor.d.ts +504 -239
  127. package/dist/editor.d.ts.map +1 -1
  128. package/dist/editor.js +1557 -706
  129. package/dist/editor.js.map +1 -1
  130. package/dist/handle.d.ts +0 -2
  131. package/dist/handle.d.ts.map +1 -1
  132. package/dist/handle.js +0 -6
  133. package/dist/handle.js.map +1 -1
  134. package/dist/index.d.ts +6 -1
  135. package/dist/index.d.ts.map +1 -1
  136. package/dist/index.js +7 -1
  137. package/dist/index.js.map +1 -1
  138. package/dist/modes.d.ts +15 -1
  139. package/dist/modes.d.ts.map +1 -1
  140. package/dist/modes.js.map +1 -1
  141. package/dist/overlay.d.ts +91 -2
  142. package/dist/overlay.d.ts.map +1 -1
  143. package/dist/overlay.js +450 -93
  144. package/dist/overlay.js.map +1 -1
  145. package/dist/search.d.ts +37 -0
  146. package/dist/search.d.ts.map +1 -0
  147. package/dist/search.js +45 -0
  148. package/dist/search.js.map +1 -0
  149. 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
- import { clampZoom } from "./public/zoom-pan.js";
19
- import { req } from "../util.js";
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
- * Pointer + wheel event binding. Owns the branchy dispatch pan /
22
- * pinch / brush / annotation / interactive-hit / machine flow —
23
- * kept in one file so reading any single handler does not require
24
- * jumping across module boundaries.
25
- *
26
- * Returns an unsubscribe function that removes every listener it
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
- export const bindPointerEvents = (editor) => {
36
- const onDown = (ev) => {
37
- ev.preventDefault();
38
- editor.host.setPointerCapture(ev.pointerId);
39
- // Give the canvas keyboard focus on press. `preventDefault` above suppresses
40
- // the browser's default focus-on-pointerdown, so without this the surface
41
- // never focuses by clicking keyboard shortcuts (and a clean blur of a
42
- // previously-focused panel input) would only kick in after tabbing to it,
43
- // which reads as "the first click did nothing". Skipped when the press lands
44
- // on a text field (in-canvas text editing) so it keeps its own focus.
45
- if (typeof editor.host.focus === "function" && !isEditableTarget(ev.target)) {
46
- editor.host.focus({ preventScroll: true });
47
- }
48
- const data = fromPointerEvent(ev, editor.host);
49
- // Fresh press — forget any additive promotion from the last gesture.
50
- editor.additivePressAdded = null;
51
- // Pan gesture detection must come BEFORE the normal flow so a
52
- // right-click or Space+left-click never starts a select/draw
53
- // gesture. Two triggers:
54
- // • Right mouse button (button === 2).
55
- // • Left mouse button + Space currently held.
56
- // Middle-click (button === 1) is covered under the same trigger.
57
- const isRightClick = ev.button === 2 || ev.button === 1;
58
- const isSpaceLeftDrag = ev.button === 0 && editor.spaceHeld;
59
- const isHandModeLeftDrag = ev.button === 0 && editor.mode === "hand";
60
- if (isRightClick || isSpaceLeftDrag || isHandModeLeftDrag) {
61
- // Suppress the next native contextmenu — we'll either pan
62
- // (if user drags) or manually fire the long-press callback
63
- // at pointerup (if it was a click-style right-click).
64
- if (isRightClick)
65
- editor.suppressNextContextMenu = true;
66
- editor.beginPanGesture(ev.pointerId, ev.button, data.point);
67
- return;
68
- }
69
- // Track every active pointer so we can detect a 2-finger pinch.
70
- // On the *second* concurrent pointer, cancel whatever single-pointer
71
- // gesture the machine started (it'd otherwise interpret the second
72
- // touch as a one-finger drag) and enter pinch mode.
73
- editor.activePointers.set(ev.pointerId, data.point);
74
- if (editor.activePointers.size === 2) {
75
- // First touch already kicked off a POINTER_DOWN — undo it so the
76
- // shape under finger #1 doesn't get dragged when finger #2 lands.
77
- editor.actor.send({ type: "POINTER_CANCEL" });
78
- editor.cancelGesture();
79
- editor.cancelLongPress();
80
- // A one-finger pan may already be in flight (first finger dragged on
81
- // empty canvas) — drop it so it doesn't fight the pinch. No menu fires
82
- // (touch pan is button 0), so clear directly rather than endPanGesture.
83
- editor.panGesture = null;
84
- editor.touchPanCandidate = null;
85
- editor.beginPinch();
86
- return;
87
- }
88
- if (editor.activePointers.size > 2) {
89
- // 3-finger and more: stay in pinch mode but ignore additional
90
- // contacts — the gesture math uses the first two pointers only.
91
- return;
92
- }
93
- // Schedule a long-press fire — cancelled by movement or release.
94
- editor.startLongPress(data.point);
95
- const worldPoint = editor.screenToWorld(data.point);
96
- // Active in-canvas text edit owns the pointer. A press inside the
97
- // edited shape repositions the caret (and starts a drag-select);
98
- // a press outside commits the edit, then falls through so the same
99
- // click does its normal thing (select / create elsewhere).
100
- if (editor.editingTextElement !== null) {
101
- if (editor.editedElementContainsPoint(worldPoint)) {
102
- editor.cancelLongPress();
103
- editor.setTextCaretFromPoint(worldPoint);
104
- return;
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
- const hit = editor.hitTest(worldPoint);
125
- const existing = hit.kind === "element" ? getElement(editor._scene, hit.id) : undefined;
126
- if (existing !== undefined && isText(existing)) {
127
- editor._selection = Selection.single(existing.id);
128
- editor.beginTextEdit(existing.id);
129
- editor.notify();
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.createTextAt(worldPoint);
343
+ editor.beginWaypointDrag(wpDragLink, i, false);
133
344
  }
134
- return;
345
+ grabbed = true;
346
+ break;
135
347
  }
136
- // Annotation pin drag — when the press lands on a pin, take over
137
- // the gesture entirely (skip machine, skip interactive testers).
138
- // Pin position updates per pointermove; commits on pointerup.
139
- const annHit = editor.hitAnnotation(worldPoint);
140
- if (annHit) {
141
- const ann = editor._scene.annotations.get(annHit);
142
- if (ann) {
143
- editor.annotationDrag = {
144
- id: annHit,
145
- originPosition: { ...ann.position },
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
- // Interactive sub-element check: when the press lands on a shape whose
154
- // type has a registered hit-tester (rich templates, etc.) and the
155
- // tester finds an interactive node, fire its emit and skip the normal
156
- // press flow entirely. This is what makes a click on a template Button
157
- // behave differently from a click on the template body.
158
- const topElement = editor.acceleratedElementAt(worldPoint);
159
- if (topElement) {
160
- const tester = getInteractiveHitTester(topElement.type);
161
- if (tester) {
162
- const local = {
163
- x: worldPoint.x - topElement.position.x,
164
- y: worldPoint.y - topElement.position.y,
165
- };
166
- const emit = tester(topElement, local);
167
- if (emit) {
168
- editor.applyEmit(emit);
169
- return;
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
- // Elbow segment drag: a press on an interior segment's midpoint handle
174
- // moves the whole segment perpendicular. Checked before the hit-test so
175
- // it isn't read as deselect.
176
- const segDragLink = editor.selectedLink;
177
- if (editor.mode === "select" && segDragLink) {
178
- const edge = getLink(editor._scene, segDragLink);
179
- if (edge && (edge.routing ?? "straight") === "orthogonal") {
180
- const path = getLinkPath(editor._scene, edge);
181
- if (path && path.length >= 2) {
182
- const zoom = editor._scene.viewport.zoom || 1;
183
- const r = LINK_ENDPOINT_HANDLE_RADIUS / zoom;
184
- const r2 = r * r;
185
- // Draggable segments: the single segment of a straight elbow (grab
186
- // to bend it → insert), or interior segments k in 1..len-3 of a
187
- // routed elbow (terminal stubs touch from/to and aren't slid).
188
- const segs = path.length === 2 ? [0] : range(1, path.length - 3);
189
- for (const k of segs) {
190
- const a = req(path[k]);
191
- const b = req(path[k + 1]);
192
- const mx = (a.x + b.x) / 2;
193
- const my = (a.y + b.y) / 2;
194
- const dx = mx - worldPoint.x;
195
- const dy = my - worldPoint.y;
196
- if (dx * dx + dy * dy <= r2) {
197
- const axis = Math.abs(a.y - b.y) < 1e-6 ? "h" : "v";
198
- const at = axis === "h" ? (a.x + b.x) / 2 : (a.y + b.y) / 2;
199
- const pos = axis === "h" ? a.y : a.x; // pinned perpendicular coord
200
- // Double-click a segment handle → drop its pin (back to auto route).
201
- if (editor.isHandleDoubleClick(worldPoint)) {
202
- editor.resetSegmentPin(segDragLink, axis, pos, at);
203
- }
204
- else {
205
- editor.beginSegmentDrag(segDragLink, axis, at);
206
- }
207
- editor.cancelLongPress();
208
- return;
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
- // Bend-point (waypoint) drag on the selected link. A press on an
215
- // existing waypoint handle moves it; a press on a segment-midpoint
216
- // handle inserts a new waypoint there (on first move). Checked before
217
- // the normal hit-test so it isn't read as deselect / new gesture.
218
- const wpDragLink = editor.selectedLink;
219
- if (editor.mode === "select" && wpDragLink) {
220
- const edge = getLink(editor._scene, wpDragLink);
221
- // Elbow links use segment-drag (separate mechanic), not free waypoints.
222
- const path = edge && (edge.routing ?? "straight") !== "orthogonal"
223
- ? getLinkPath(editor._scene, edge)
224
- : null;
225
- if (edge && path && path.length >= 2) {
226
- const zoom = editor._scene.viewport.zoom || 1;
227
- const r = LINK_ENDPOINT_HANDLE_RADIUS / zoom;
228
- const r2 = r * r;
229
- const waypoints = [...(edge.waypoints ?? [])];
230
- const within = (p) => {
231
- const dx = p.x - worldPoint.x;
232
- const dy = p.y - worldPoint.y;
233
- return dx * dx + dy * dy <= r2;
234
- };
235
- // Existing waypoints take priority over the midpoint "add" handles.
236
- let grabbed = false;
237
- for (let i = 0; i < waypoints.length; i++) {
238
- if (within(req(waypoints[i]))) {
239
- // Double-click a waypoint handle → delete the bend point.
240
- if (editor.isHandleDoubleClick(worldPoint)) {
241
- editor.deleteWaypoint(wpDragLink, i);
242
- }
243
- else {
244
- editor.beginWaypointDrag(wpDragLink, i, false);
245
- }
246
- grabbed = true;
247
- break;
248
- }
249
- }
250
- if (!grabbed) {
251
- // "Add waypoint" handles sit on the drawn arc (bezier) / chord
252
- // (straight) same geometry the overlay renders, so the click target
253
- // matches the visible handle.
254
- const mids = getLinkWaypointMidpoints(editor._scene, edge) ?? [];
255
- for (let i = 0; i < mids.length; i++) {
256
- if (within(req(mids[i]))) {
257
- editor.beginWaypointDrag(wpDragLink, i, true);
258
- grabbed = true;
259
- break;
260
- }
261
- }
262
- }
263
- if (grabbed) {
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
- // Resolve the press target up-front so the anchor-drag path below can defer
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
- // Notify happens at the end of the gesture path; selecting now
351
- // ensures the live `_selection` reflects what subsequent
352
- // MOVE_SHAPE emits will operate on.
353
- editor.notify();
354
- }
355
- // ⌥-drag duplicate: holding Alt and pressing a selected element clones
356
- // the whole selection IN PLACE (incl. frame contents / group
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
- else {
435
- editor.groupMoveOrigin = null;
436
- editor.groupLinkMoveOrigin = null;
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
- // Snapshot each member's world bounds + position + scale when the
448
- // press lands on a group-handle so the per-frame resize math has
449
- // a stable baseline to scale against.
450
- //
451
- // For single-group selection the selection itself is just the
452
- // group wrapper (zero intrinsic bounds), so the snapshot would
453
- // be useless. Expand to include every descendant those are
454
- // the leaves applyGroupResize actually scales. Same expansion
455
- // is harmless for plain multi-selection (no descendants).
456
- if (target.kind === "group-handle") {
457
- const elements = new Map();
458
- for (const id of editor.expandSelectionWithDescendants()) {
459
- const s = getElement(editor._scene, id);
460
- if (!s)
461
- continue;
462
- elements.set(id, {
463
- position: s.position,
464
- bounds: getElementWorldBounds(s),
465
- scale: s.scale,
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
- // Links that scale with the box: selected links + connectors bound on
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
- else {
474
- editor.groupResizeOrigin = null;
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
- // Snapshot every member's pose when the press lands on the rotate grip, so
477
- // the per-frame rotate math turns from a stable baseline. Expand to
478
- // descendants (the leaves that actually carry geometry), matching resize.
479
- if (target.kind === "rotate-handle") {
480
- const origin = new Map();
481
- for (const id of editor.expandSelectionWithDescendants()) {
482
- const s = getElement(editor._scene, id);
483
- if (s)
484
- origin.set(id, { position: s.position, rotation: s.rotation });
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
- editor.rotateGestureOrigin = { pivot: target.pivot, origin };
887
+ }
888
+ if (onDot) {
889
+ editor.createLinkedElementFromAnchor(drag.fromElement, drag.anchorName);
487
890
  }
488
891
  else {
489
- editor.rotateGestureOrigin = null;
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
- // Arm one-finger pan: a TOUCH press on empty canvas in select mode.
492
- // A tap still deselects via the machine below; onMove promotes this to
493
- // a pan once the finger drags past slop (instead of a marquee lasso).
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
- // Pan gesture in flight — translate cursor delta to a screen
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 that want a
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 below
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
- // Host-managed elbow segment drag of the selected link.
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
- // Fan out to anyone listening for the local cursor — `@collab`
658
- // broadcasts it via awareness. Fires on every move; subscribers
659
- // throttle if they care.
660
- for (const fn of editor.cursorListeners)
661
- fn(worldPoint);
662
- const ctx = editor.actor.getSnapshot().context;
663
- if (ctx.pressOrigin &&
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 (a tap
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
- // Pan gesture ends — clean up cursor and state, skip the rest.
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
- // Commit a host-managed elbow segment drag (one undo step).
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
- // Annotation drag commit — issue a single patch that goes from
804
- // origin position to final position so history has one undo step.
805
- if (editor.annotationDrag) {
806
- const drag = editor.annotationDrag;
807
- editor.annotationDrag = null;
808
- if (drag.moved) {
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
- // Cmd/Ctrl-click on a linked element (a tap, not a drag) opens its
827
- // href — onDown already skipped the additive-select promote for this
828
- // case, so selection is untouched.
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
- // right mouse press, AFTER pointerup on most browsers. We use
970
- // `suppressNextContextMenu` (set on right-click pointerdown) to:
971
- // preventDefault the native browser menu;
972
- // stopPropagation so window-level listeners (like
973
- // `@react-ui/ContextMenu` default) don't re-open a menu when
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
- // arms the next mouse drag as a pan. Skip when focus is in a
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 — common in browsers when no
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
- // Browsers fire identical `wheel` events for both devices and no
1023
- // per-event signal reliably distinguishes them.
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
- // trackpad swipes also land here).
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
- const applyZoom = () => {
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
- applyZoom();
1360
+ applyWheelZoom(editor, ev, screenPoint);
1074
1361
  return;
1075
1362
  }
1076
- // Trackpad 2-finger swipe with any horizontal component →
1077
- // pan both axes. Mouse wheels never set deltaX, so this
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
- applyPan();
1366
+ applyWheelPan(editor, ev);
1081
1367
  return;
1082
1368
  }
1083
- // Plain vertical wheel — always ZOOM. Pure-vertical trackpad swipes
1084
- // also hit this; users who want vertical-only trackpad pan use
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
- // listeners to passive — must opt out explicitly.
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);