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