@oh-just-another/state 0.57.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 (269) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +60 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/actions/actionClipboard.d.ts +6 -0
  6. package/dist/actions/actionClipboard.d.ts.map +1 -0
  7. package/dist/actions/actionClipboard.js +26 -0
  8. package/dist/actions/actionClipboard.js.map +1 -0
  9. package/dist/actions/actionGrouping.d.ts +5 -0
  10. package/dist/actions/actionGrouping.d.ts.map +1 -0
  11. package/dist/actions/actionGrouping.js +26 -0
  12. package/dist/actions/actionGrouping.js.map +1 -0
  13. package/dist/actions/actionHistory.d.ts +5 -0
  14. package/dist/actions/actionHistory.d.ts.map +1 -0
  15. package/dist/actions/actionHistory.js +25 -0
  16. package/dist/actions/actionHistory.js.map +1 -0
  17. package/dist/actions/actionKeyboard.d.ts +3 -0
  18. package/dist/actions/actionKeyboard.d.ts.map +1 -0
  19. package/dist/actions/actionKeyboard.js +130 -0
  20. package/dist/actions/actionKeyboard.js.map +1 -0
  21. package/dist/actions/actionLayout.d.ts +3 -0
  22. package/dist/actions/actionLayout.d.ts.map +1 -0
  23. package/dist/actions/actionLayout.js +65 -0
  24. package/dist/actions/actionLayout.js.map +1 -0
  25. package/dist/actions/actionMode.d.ts +13 -0
  26. package/dist/actions/actionMode.d.ts.map +1 -0
  27. package/dist/actions/actionMode.js +116 -0
  28. package/dist/actions/actionMode.js.map +1 -0
  29. package/dist/actions/actionSelection.d.ts +9 -0
  30. package/dist/actions/actionSelection.d.ts.map +1 -0
  31. package/dist/actions/actionSelection.js +57 -0
  32. package/dist/actions/actionSelection.js.map +1 -0
  33. package/dist/actions/actionView.d.ts +7 -0
  34. package/dist/actions/actionView.d.ts.map +1 -0
  35. package/dist/actions/actionView.js +14 -0
  36. package/dist/actions/actionView.js.map +1 -0
  37. package/dist/actions/actionZOrder.d.ts +7 -0
  38. package/dist/actions/actionZOrder.d.ts.map +1 -0
  39. package/dist/actions/actionZOrder.js +42 -0
  40. package/dist/actions/actionZOrder.js.map +1 -0
  41. package/dist/actions/actionZoom.d.ts +8 -0
  42. package/dist/actions/actionZoom.d.ts.map +1 -0
  43. package/dist/actions/actionZoom.js +61 -0
  44. package/dist/actions/actionZoom.js.map +1 -0
  45. package/dist/actions/index.d.ts +12 -0
  46. package/dist/actions/index.d.ts.map +1 -0
  47. package/dist/actions/index.js +11 -0
  48. package/dist/actions/index.js.map +1 -0
  49. package/dist/actions/registry.d.ts +68 -0
  50. package/dist/actions/registry.d.ts.map +1 -0
  51. package/dist/actions/registry.js +300 -0
  52. package/dist/actions/registry.js.map +1 -0
  53. package/dist/actions/types.d.ts +103 -0
  54. package/dist/actions/types.d.ts.map +1 -0
  55. package/dist/actions/types.js +10 -0
  56. package/dist/actions/types.js.map +1 -0
  57. package/dist/animation-tick.d.ts +28 -0
  58. package/dist/animation-tick.d.ts.map +1 -0
  59. package/dist/animation-tick.js +50 -0
  60. package/dist/animation-tick.js.map +1 -0
  61. package/dist/auto-compact.d.ts +27 -0
  62. package/dist/auto-compact.d.ts.map +1 -0
  63. package/dist/auto-compact.js +46 -0
  64. package/dist/auto-compact.js.map +1 -0
  65. package/dist/auto-layout-scheduler.d.ts +50 -0
  66. package/dist/auto-layout-scheduler.d.ts.map +1 -0
  67. package/dist/auto-layout-scheduler.js +94 -0
  68. package/dist/auto-layout-scheduler.js.map +1 -0
  69. package/dist/built-in-handlers.d.ts +24 -0
  70. package/dist/built-in-handlers.d.ts.map +1 -0
  71. package/dist/built-in-handlers.js +210 -0
  72. package/dist/built-in-handlers.js.map +1 -0
  73. package/dist/clipboard.d.ts +51 -0
  74. package/dist/clipboard.d.ts.map +1 -0
  75. package/dist/clipboard.js +105 -0
  76. package/dist/clipboard.js.map +1 -0
  77. package/dist/constants.d.ts +544 -0
  78. package/dist/constants.d.ts.map +1 -0
  79. package/dist/constants.js +537 -0
  80. package/dist/constants.js.map +1 -0
  81. package/dist/dom-events.d.ts +13 -0
  82. package/dist/dom-events.d.ts.map +1 -0
  83. package/dist/dom-events.js +70 -0
  84. package/dist/dom-events.js.map +1 -0
  85. package/dist/editor/anchor-points.d.ts +22 -0
  86. package/dist/editor/anchor-points.d.ts.map +1 -0
  87. package/dist/editor/anchor-points.js +28 -0
  88. package/dist/editor/anchor-points.js.map +1 -0
  89. package/dist/editor/applies/create.d.ts +60 -0
  90. package/dist/editor/applies/create.d.ts.map +1 -0
  91. package/dist/editor/applies/create.js +107 -0
  92. package/dist/editor/applies/create.js.map +1 -0
  93. package/dist/editor/applies/edge.d.ts +32 -0
  94. package/dist/editor/applies/edge.d.ts.map +1 -0
  95. package/dist/editor/applies/edge.js +44 -0
  96. package/dist/editor/applies/edge.js.map +1 -0
  97. package/dist/editor/applies/link-move.d.ts +53 -0
  98. package/dist/editor/applies/link-move.d.ts.map +1 -0
  99. package/dist/editor/applies/link-move.js +160 -0
  100. package/dist/editor/applies/link-move.js.map +1 -0
  101. package/dist/editor/applies/move.d.ts +38 -0
  102. package/dist/editor/applies/move.d.ts.map +1 -0
  103. package/dist/editor/applies/move.js +83 -0
  104. package/dist/editor/applies/move.js.map +1 -0
  105. package/dist/editor/applies/resize.d.ts +68 -0
  106. package/dist/editor/applies/resize.d.ts.map +1 -0
  107. package/dist/editor/applies/resize.js +199 -0
  108. package/dist/editor/applies/resize.js.map +1 -0
  109. package/dist/editor/applies/selection.d.ts +34 -0
  110. package/dist/editor/applies/selection.d.ts.map +1 -0
  111. package/dist/editor/applies/selection.js +65 -0
  112. package/dist/editor/applies/selection.js.map +1 -0
  113. package/dist/editor/applies/snap-grid.d.ts +45 -0
  114. package/dist/editor/applies/snap-grid.d.ts.map +1 -0
  115. package/dist/editor/applies/snap-grid.js +90 -0
  116. package/dist/editor/applies/snap-grid.js.map +1 -0
  117. package/dist/editor/container-ops.d.ts +74 -0
  118. package/dist/editor/container-ops.d.ts.map +1 -0
  119. package/dist/editor/container-ops.js +262 -0
  120. package/dist/editor/container-ops.js.map +1 -0
  121. package/dist/editor/event-fanout.d.ts +48 -0
  122. package/dist/editor/event-fanout.d.ts.map +1 -0
  123. package/dist/editor/event-fanout.js +53 -0
  124. package/dist/editor/event-fanout.js.map +1 -0
  125. package/dist/editor/gesture-tx.d.ts +77 -0
  126. package/dist/editor/gesture-tx.d.ts.map +1 -0
  127. package/dist/editor/gesture-tx.js +93 -0
  128. package/dist/editor/gesture-tx.js.map +1 -0
  129. package/dist/editor/hit-test.d.ts +178 -0
  130. package/dist/editor/hit-test.d.ts.map +1 -0
  131. package/dist/editor/hit-test.js +460 -0
  132. package/dist/editor/hit-test.js.map +1 -0
  133. package/dist/editor/long-press.d.ts +34 -0
  134. package/dist/editor/long-press.d.ts.map +1 -0
  135. package/dist/editor/long-press.js +58 -0
  136. package/dist/editor/long-press.js.map +1 -0
  137. package/dist/editor/pinch.d.ts +37 -0
  138. package/dist/editor/pinch.d.ts.map +1 -0
  139. package/dist/editor/pinch.js +90 -0
  140. package/dist/editor/pinch.js.map +1 -0
  141. package/dist/editor/pointer-binding.d.ts +18 -0
  142. package/dist/editor/pointer-binding.d.ts.map +1 -0
  143. package/dist/editor/pointer-binding.js +1087 -0
  144. package/dist/editor/pointer-binding.js.map +1 -0
  145. package/dist/editor/public/annotations.d.ts +54 -0
  146. package/dist/editor/public/annotations.d.ts.map +1 -0
  147. package/dist/editor/public/annotations.js +106 -0
  148. package/dist/editor/public/annotations.js.map +1 -0
  149. package/dist/editor/public/arrange-group.d.ts +81 -0
  150. package/dist/editor/public/arrange-group.d.ts.map +1 -0
  151. package/dist/editor/public/arrange-group.js +193 -0
  152. package/dist/editor/public/arrange-group.js.map +1 -0
  153. package/dist/editor/public/brush.d.ts +33 -0
  154. package/dist/editor/public/brush.d.ts.map +1 -0
  155. package/dist/editor/public/brush.js +54 -0
  156. package/dist/editor/public/brush.js.map +1 -0
  157. package/dist/editor/public/clipboard.d.ts +31 -0
  158. package/dist/editor/public/clipboard.d.ts.map +1 -0
  159. package/dist/editor/public/clipboard.js +35 -0
  160. package/dist/editor/public/clipboard.js.map +1 -0
  161. package/dist/editor/public/image-insert.d.ts +30 -0
  162. package/dist/editor/public/image-insert.d.ts.map +1 -0
  163. package/dist/editor/public/image-insert.js +57 -0
  164. package/dist/editor/public/image-insert.js.map +1 -0
  165. package/dist/editor/public/layers.d.ts +56 -0
  166. package/dist/editor/public/layers.d.ts.map +1 -0
  167. package/dist/editor/public/layers.js +104 -0
  168. package/dist/editor/public/layers.js.map +1 -0
  169. package/dist/editor/public/link.d.ts +25 -0
  170. package/dist/editor/public/link.d.ts.map +1 -0
  171. package/dist/editor/public/link.js +74 -0
  172. package/dist/editor/public/link.js.map +1 -0
  173. package/dist/editor/public/placement.d.ts +60 -0
  174. package/dist/editor/public/placement.d.ts.map +1 -0
  175. package/dist/editor/public/placement.js +111 -0
  176. package/dist/editor/public/placement.js.map +1 -0
  177. package/dist/editor/public/selection-ops.d.ts +78 -0
  178. package/dist/editor/public/selection-ops.d.ts.map +1 -0
  179. package/dist/editor/public/selection-ops.js +226 -0
  180. package/dist/editor/public/selection-ops.js.map +1 -0
  181. package/dist/editor/public/text-edit.d.ts +17 -0
  182. package/dist/editor/public/text-edit.d.ts.map +1 -0
  183. package/dist/editor/public/text-edit.js +28 -0
  184. package/dist/editor/public/text-edit.js.map +1 -0
  185. package/dist/editor/public/z-order.d.ts +47 -0
  186. package/dist/editor/public/z-order.d.ts.map +1 -0
  187. package/dist/editor/public/z-order.js +151 -0
  188. package/dist/editor/public/z-order.js.map +1 -0
  189. package/dist/editor/public/zoom-pan.d.ts +44 -0
  190. package/dist/editor/public/zoom-pan.d.ts.map +1 -0
  191. package/dist/editor/public/zoom-pan.js +116 -0
  192. package/dist/editor/public/zoom-pan.js.map +1 -0
  193. package/dist/editor/render-orchestrator.d.ts +11 -0
  194. package/dist/editor/render-orchestrator.d.ts.map +1 -0
  195. package/dist/editor/render-orchestrator.js +473 -0
  196. package/dist/editor/render-orchestrator.js.map +1 -0
  197. package/dist/editor/resize-helpers.d.ts +30 -0
  198. package/dist/editor/resize-helpers.d.ts.map +1 -0
  199. package/dist/editor/resize-helpers.js +121 -0
  200. package/dist/editor/resize-helpers.js.map +1 -0
  201. package/dist/editor/shape-filters.d.ts +11 -0
  202. package/dist/editor/shape-filters.d.ts.map +1 -0
  203. package/dist/editor/shape-filters.js +19 -0
  204. package/dist/editor/shape-filters.js.map +1 -0
  205. package/dist/editor/shape-traits.d.ts +16 -0
  206. package/dist/editor/shape-traits.d.ts.map +1 -0
  207. package/dist/editor/shape-traits.js +29 -0
  208. package/dist/editor/shape-traits.js.map +1 -0
  209. package/dist/editor/viewport-helpers.d.ts +24 -0
  210. package/dist/editor/viewport-helpers.d.ts.map +1 -0
  211. package/dist/editor/viewport-helpers.js +63 -0
  212. package/dist/editor/viewport-helpers.js.map +1 -0
  213. package/dist/editor-events.d.ts +33 -0
  214. package/dist/editor-events.d.ts.map +1 -0
  215. package/dist/editor-events.js +2 -0
  216. package/dist/editor-events.js.map +1 -0
  217. package/dist/editor.d.ts +1841 -0
  218. package/dist/editor.d.ts.map +1 -0
  219. package/dist/editor.js +4803 -0
  220. package/dist/editor.js.map +1 -0
  221. package/dist/file-drop.d.ts +99 -0
  222. package/dist/file-drop.d.ts.map +1 -0
  223. package/dist/file-drop.js +198 -0
  224. package/dist/file-drop.js.map +1 -0
  225. package/dist/frame-helpers.d.ts +30 -0
  226. package/dist/frame-helpers.d.ts.map +1 -0
  227. package/dist/frame-helpers.js +105 -0
  228. package/dist/frame-helpers.js.map +1 -0
  229. package/dist/group-helpers.d.ts +39 -0
  230. package/dist/group-helpers.d.ts.map +1 -0
  231. package/dist/group-helpers.js +116 -0
  232. package/dist/group-helpers.js.map +1 -0
  233. package/dist/handle.d.ts +73 -0
  234. package/dist/handle.d.ts.map +1 -0
  235. package/dist/handle.js +184 -0
  236. package/dist/handle.js.map +1 -0
  237. package/dist/index.d.ts +26 -0
  238. package/dist/index.d.ts.map +1 -0
  239. package/dist/index.js +16 -0
  240. package/dist/index.js.map +1 -0
  241. package/dist/interactive.d.ts +21 -0
  242. package/dist/interactive.d.ts.map +1 -0
  243. package/dist/interactive.js +11 -0
  244. package/dist/interactive.js.map +1 -0
  245. package/dist/link-selection.d.ts +21 -0
  246. package/dist/link-selection.d.ts.map +1 -0
  247. package/dist/link-selection.js +37 -0
  248. package/dist/link-selection.js.map +1 -0
  249. package/dist/machine.d.ts +322 -0
  250. package/dist/machine.d.ts.map +1 -0
  251. package/dist/machine.js +568 -0
  252. package/dist/machine.js.map +1 -0
  253. package/dist/modes.d.ts +24 -0
  254. package/dist/modes.d.ts.map +1 -0
  255. package/dist/modes.js +2 -0
  256. package/dist/modes.js.map +1 -0
  257. package/dist/overlay.d.ts +262 -0
  258. package/dist/overlay.d.ts.map +1 -0
  259. package/dist/overlay.js +673 -0
  260. package/dist/overlay.js.map +1 -0
  261. package/dist/platform.d.ts +70 -0
  262. package/dist/platform.d.ts.map +1 -0
  263. package/dist/platform.js +115 -0
  264. package/dist/platform.js.map +1 -0
  265. package/dist/selection.d.ts +18 -0
  266. package/dist/selection.d.ts.map +1 -0
  267. package/dist/selection.js +31 -0
  268. package/dist/selection.js.map +1 -0
  269. package/package.json +59 -0
@@ -0,0 +1,1087 @@
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";
4
+ import * as Selection from "../selection.js";
5
+ import * as LinkSelection from "../link-selection.js";
6
+ import { getInteractiveHitTester } from "../interactive.js";
7
+ import { anchorOverlayPoints } from "./anchor-points.js";
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";
10
+ /** Inclusive integer range `[a..b]`; empty when `b < a`. */
11
+ const range = (a, b) => {
12
+ const out = [];
13
+ for (let i = a; i <= b; i++)
14
+ out.push(i);
15
+ return out;
16
+ };
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;
24
+ };
25
+ /**
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.
39
+ */
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;
101
+ }
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
+ 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();
126
+ }
127
+ else {
128
+ editor.createTextAt(worldPoint);
129
+ }
130
+ return;
131
+ }
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;
147
+ }
148
+ }
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
+ }
168
+ }
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
+ }
209
+ }
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;
262
+ }
263
+ }
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;
341
+ }
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;
425
+ }
426
+ else {
427
+ editor.groupMoveOrigin = null;
428
+ editor.groupLinkMoveOrigin = null;
429
+ }
430
+ }
431
+ else {
432
+ editor.groupMoveOrigin = null;
433
+ editor.groupLinkMoveOrigin = null;
434
+ }
435
+ }
436
+ else {
437
+ editor.groupMoveOrigin = null;
438
+ }
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
+ });
459
+ }
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 };
464
+ }
465
+ else {
466
+ editor.groupResizeOrigin = null;
467
+ }
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).
471
+ editor.touchPanCandidate =
472
+ data.kind === "touch" && editor.mode === "select" && target.kind === "empty"
473
+ ? data.point
474
+ : null;
475
+ editor.actor.send({
476
+ type: "POINTER_DOWN",
477
+ point: worldPoint,
478
+ target,
479
+ modifiers: data.modifiers,
480
+ });
481
+ };
482
+ const onMove = (ev) => {
483
+ 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 });
502
+ 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();
511
+ 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
+ }
528
+ return;
529
+ }
530
+ // Cancel long-press timer if the finger has moved beyond slop.
531
+ editor.longPress.cancelIfMovedBeyond(data.point, LONG_PRESS_MAX_MOVEMENT_PX);
532
+ const worldPoint = editor.screenToWorld(data.point);
533
+ // Track cursor for paste-at-cursor and other commands that want a
534
+ // sensible drop target.
535
+ editor.lastPointerWorld = worldPoint;
536
+ // Context cursor: recompute every move (before the gesture branches below
537
+ // early-return) so it reflects hover targets AND active gestures.
538
+ editor.refreshCursor(worldPoint);
539
+ // Host-managed elbow segment drag of the selected link.
540
+ if (editor.isDraggingSegment) {
541
+ editor.updateSegmentDrag(worldPoint);
542
+ return;
543
+ }
544
+ // Host-managed waypoint (bend-point) drag of the selected link.
545
+ if (editor.isDraggingWaypoint) {
546
+ editor.updateWaypointDrag(worldPoint);
547
+ 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);
565
+ return;
566
+ }
567
+ // Drag-select inside the edited text shape.
568
+ if (editor.editingTextElement !== null && editor.isTextDragging) {
569
+ editor.extendTextSelectionToPoint(worldPoint);
570
+ 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);
576
+ 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
+ }
632
+ 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 });
672
+ };
673
+ const onUp = (ev) => {
674
+ if (editor.host.hasPointerCapture(ev.pointerId)) {
675
+ editor.host.releasePointerCapture(ev.pointerId);
676
+ }
677
+ 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).
680
+ 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();
684
+ 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();
692
+ return;
693
+ }
694
+ // Long-press loses its chance the moment the user releases.
695
+ editor.cancelLongPress();
696
+ // Commit a host-managed elbow segment drag (one undo step).
697
+ if (editor.isDraggingSegment) {
698
+ editor.endSegmentDrag();
699
+ 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();
705
+ 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();
768
+ return;
769
+ }
770
+ // End an in-canvas text drag-select.
771
+ if (editor.editingTextElement !== null && editor.isTextDragging) {
772
+ editor.endTextDragSelect();
773
+ return;
774
+ }
775
+ // Commit brush stroke if one is in progress.
776
+ if (editor.brushStroke) {
777
+ editor.commitBrushStroke();
778
+ 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
+ }
799
+ return;
800
+ }
801
+ const data = fromPointerEvent(ev, editor.host);
802
+ 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);
897
+ };
898
+ const onCancel = (ev) => {
899
+ editor.activePointers.delete(ev.pointerId);
900
+ editor.touchPanCandidate = null;
901
+ if (editor.panGesture?.pointerId === ev.pointerId) {
902
+ editor.endPanGesture();
903
+ return;
904
+ }
905
+ if (editor.pinch.isActive()) {
906
+ if (editor.activePointers.size < 2)
907
+ editor.pinch.end();
908
+ return;
909
+ }
910
+ editor.cancelLongPress();
911
+ // Abort a link-from-anchor drag — drop the preview/hover, create
912
+ // nothing.
913
+ if (editor.linkDragFromAnchor) {
914
+ editor.linkDragFromAnchor = null;
915
+ editor.edgePreview = null;
916
+ editor.hoveredLinkTarget = null;
917
+ editor.notify();
918
+ return;
919
+ }
920
+ if (editor.brushStroke) {
921
+ editor.cancelBrushStroke();
922
+ return;
923
+ }
924
+ // Annotation drag — revert to origin on cancel.
925
+ if (editor.annotationDrag) {
926
+ const drag = editor.annotationDrag;
927
+ editor.annotationDrag = null;
928
+ const ann = editor._scene.annotations.get(drag.id);
929
+ if (ann) {
930
+ const annotations = new Map(editor._scene.annotations);
931
+ annotations.set(drag.id, { ...ann, position: drag.originPosition });
932
+ editor._scene = { ...editor._scene, annotations };
933
+ editor.notify();
934
+ }
935
+ return;
936
+ }
937
+ editor.drawingPreview = null;
938
+ editor.actor.send({ type: "POINTER_CANCEL" });
939
+ editor.cancelGesture();
940
+ };
941
+ editor.host.addEventListener("pointerdown", onDown);
942
+ editor.host.addEventListener("pointermove", onMove);
943
+ editor.host.addEventListener("pointerup", onUp);
944
+ 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.
957
+ const onContextMenu = (ev) => {
958
+ if (!editor.suppressNextContextMenu)
959
+ return;
960
+ editor.suppressNextContextMenu = false;
961
+ ev.preventDefault();
962
+ ev.stopPropagation();
963
+ };
964
+ // Capture phase so we beat the window-level listener that
965
+ // ContextMenu attaches in its useEffect.
966
+ 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
+ };
976
+ const onKeyDown = (ev) => {
977
+ if (ev.code !== "Space" && ev.key !== " ")
978
+ return;
979
+ if (isEditableTarget(ev.target))
980
+ return;
981
+ if (editor.spaceHeld)
982
+ return;
983
+ editor.spaceHeld = true;
984
+ // Visual affordance: "grab" cursor signals the user can drag-pan.
985
+ 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.
988
+ ev.preventDefault();
989
+ };
990
+ const onKeyUp = (ev) => {
991
+ if (ev.code !== "Space" && ev.key !== " ")
992
+ return;
993
+ if (!editor.spaceHeld)
994
+ return;
995
+ editor.spaceHeld = false;
996
+ // Recompute (→ "grabbing" if a pan is still in flight, else idle hover).
997
+ editor.refreshCursor();
998
+ };
999
+ // window guard so node-env tests can still construct the editor.
1000
+ if (typeof window !== "undefined") {
1001
+ window.addEventListener("keydown", onKeyDown);
1002
+ window.addEventListener("keyup", onKeyUp);
1003
+ }
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.
1012
+ // • 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.
1020
+ const onWheel = (ev) => {
1021
+ ev.preventDefault();
1022
+ const rect = editor.host.getBoundingClientRect();
1023
+ 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).
1055
+ if (ev.ctrlKey || ev.metaKey) {
1056
+ applyZoom();
1057
+ return;
1058
+ }
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.
1062
+ if (ev.deltaX !== 0) {
1063
+ applyPan();
1064
+ return;
1065
+ }
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();
1070
+ };
1071
+ // `passive: false` because we preventDefault. Browsers default wheel
1072
+ // listeners to passive — must opt out explicitly.
1073
+ editor.host.addEventListener("wheel", onWheel, { passive: false });
1074
+ return () => {
1075
+ editor.host.removeEventListener("pointerdown", onDown);
1076
+ editor.host.removeEventListener("pointermove", onMove);
1077
+ editor.host.removeEventListener("pointerup", onUp);
1078
+ editor.host.removeEventListener("pointercancel", onCancel);
1079
+ editor.host.removeEventListener("contextmenu", onContextMenu, true);
1080
+ editor.host.removeEventListener("wheel", onWheel);
1081
+ if (typeof window !== "undefined") {
1082
+ window.removeEventListener("keydown", onKeyDown);
1083
+ window.removeEventListener("keyup", onKeyUp);
1084
+ }
1085
+ };
1086
+ };
1087
+ //# sourceMappingURL=pointer-binding.js.map