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