@oh-just-another/state 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +60 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/actions/actionClipboard.d.ts +6 -0
  6. package/dist/actions/actionClipboard.d.ts.map +1 -0
  7. package/dist/actions/actionClipboard.js +26 -0
  8. package/dist/actions/actionClipboard.js.map +1 -0
  9. package/dist/actions/actionGrouping.d.ts +5 -0
  10. package/dist/actions/actionGrouping.d.ts.map +1 -0
  11. package/dist/actions/actionGrouping.js +26 -0
  12. package/dist/actions/actionGrouping.js.map +1 -0
  13. package/dist/actions/actionHistory.d.ts +5 -0
  14. package/dist/actions/actionHistory.d.ts.map +1 -0
  15. package/dist/actions/actionHistory.js +25 -0
  16. package/dist/actions/actionHistory.js.map +1 -0
  17. package/dist/actions/actionKeyboard.d.ts +3 -0
  18. package/dist/actions/actionKeyboard.d.ts.map +1 -0
  19. package/dist/actions/actionKeyboard.js +130 -0
  20. package/dist/actions/actionKeyboard.js.map +1 -0
  21. package/dist/actions/actionLayout.d.ts +3 -0
  22. package/dist/actions/actionLayout.d.ts.map +1 -0
  23. package/dist/actions/actionLayout.js +65 -0
  24. package/dist/actions/actionLayout.js.map +1 -0
  25. package/dist/actions/actionMode.d.ts +13 -0
  26. package/dist/actions/actionMode.d.ts.map +1 -0
  27. package/dist/actions/actionMode.js +116 -0
  28. package/dist/actions/actionMode.js.map +1 -0
  29. package/dist/actions/actionSelection.d.ts +9 -0
  30. package/dist/actions/actionSelection.d.ts.map +1 -0
  31. package/dist/actions/actionSelection.js +57 -0
  32. package/dist/actions/actionSelection.js.map +1 -0
  33. package/dist/actions/actionView.d.ts +7 -0
  34. package/dist/actions/actionView.d.ts.map +1 -0
  35. package/dist/actions/actionView.js +14 -0
  36. package/dist/actions/actionView.js.map +1 -0
  37. package/dist/actions/actionZOrder.d.ts +7 -0
  38. package/dist/actions/actionZOrder.d.ts.map +1 -0
  39. package/dist/actions/actionZOrder.js +42 -0
  40. package/dist/actions/actionZOrder.js.map +1 -0
  41. package/dist/actions/actionZoom.d.ts +8 -0
  42. package/dist/actions/actionZoom.d.ts.map +1 -0
  43. package/dist/actions/actionZoom.js +61 -0
  44. package/dist/actions/actionZoom.js.map +1 -0
  45. package/dist/actions/index.d.ts +12 -0
  46. package/dist/actions/index.d.ts.map +1 -0
  47. package/dist/actions/index.js +11 -0
  48. package/dist/actions/index.js.map +1 -0
  49. package/dist/actions/registry.d.ts +68 -0
  50. package/dist/actions/registry.d.ts.map +1 -0
  51. package/dist/actions/registry.js +300 -0
  52. package/dist/actions/registry.js.map +1 -0
  53. package/dist/actions/types.d.ts +103 -0
  54. package/dist/actions/types.d.ts.map +1 -0
  55. package/dist/actions/types.js +10 -0
  56. package/dist/actions/types.js.map +1 -0
  57. package/dist/animation-tick.d.ts +28 -0
  58. package/dist/animation-tick.d.ts.map +1 -0
  59. package/dist/animation-tick.js +50 -0
  60. package/dist/animation-tick.js.map +1 -0
  61. package/dist/auto-compact.d.ts +27 -0
  62. package/dist/auto-compact.d.ts.map +1 -0
  63. package/dist/auto-compact.js +46 -0
  64. package/dist/auto-compact.js.map +1 -0
  65. package/dist/auto-layout-scheduler.d.ts +50 -0
  66. package/dist/auto-layout-scheduler.d.ts.map +1 -0
  67. package/dist/auto-layout-scheduler.js +94 -0
  68. package/dist/auto-layout-scheduler.js.map +1 -0
  69. package/dist/built-in-handlers.d.ts +24 -0
  70. package/dist/built-in-handlers.d.ts.map +1 -0
  71. package/dist/built-in-handlers.js +210 -0
  72. package/dist/built-in-handlers.js.map +1 -0
  73. package/dist/clipboard.d.ts +51 -0
  74. package/dist/clipboard.d.ts.map +1 -0
  75. package/dist/clipboard.js +105 -0
  76. package/dist/clipboard.js.map +1 -0
  77. package/dist/constants.d.ts +544 -0
  78. package/dist/constants.d.ts.map +1 -0
  79. package/dist/constants.js +537 -0
  80. package/dist/constants.js.map +1 -0
  81. package/dist/dom-events.d.ts +13 -0
  82. package/dist/dom-events.d.ts.map +1 -0
  83. package/dist/dom-events.js +70 -0
  84. package/dist/dom-events.js.map +1 -0
  85. package/dist/editor/anchor-points.d.ts +22 -0
  86. package/dist/editor/anchor-points.d.ts.map +1 -0
  87. package/dist/editor/anchor-points.js +28 -0
  88. package/dist/editor/anchor-points.js.map +1 -0
  89. package/dist/editor/applies/create.d.ts +60 -0
  90. package/dist/editor/applies/create.d.ts.map +1 -0
  91. package/dist/editor/applies/create.js +107 -0
  92. package/dist/editor/applies/create.js.map +1 -0
  93. package/dist/editor/applies/edge.d.ts +32 -0
  94. package/dist/editor/applies/edge.d.ts.map +1 -0
  95. package/dist/editor/applies/edge.js +44 -0
  96. package/dist/editor/applies/edge.js.map +1 -0
  97. package/dist/editor/applies/link-move.d.ts +53 -0
  98. package/dist/editor/applies/link-move.d.ts.map +1 -0
  99. package/dist/editor/applies/link-move.js +160 -0
  100. package/dist/editor/applies/link-move.js.map +1 -0
  101. package/dist/editor/applies/move.d.ts +38 -0
  102. package/dist/editor/applies/move.d.ts.map +1 -0
  103. package/dist/editor/applies/move.js +83 -0
  104. package/dist/editor/applies/move.js.map +1 -0
  105. package/dist/editor/applies/resize.d.ts +68 -0
  106. package/dist/editor/applies/resize.d.ts.map +1 -0
  107. package/dist/editor/applies/resize.js +199 -0
  108. package/dist/editor/applies/resize.js.map +1 -0
  109. package/dist/editor/applies/selection.d.ts +34 -0
  110. package/dist/editor/applies/selection.d.ts.map +1 -0
  111. package/dist/editor/applies/selection.js +65 -0
  112. package/dist/editor/applies/selection.js.map +1 -0
  113. package/dist/editor/applies/snap-grid.d.ts +45 -0
  114. package/dist/editor/applies/snap-grid.d.ts.map +1 -0
  115. package/dist/editor/applies/snap-grid.js +90 -0
  116. package/dist/editor/applies/snap-grid.js.map +1 -0
  117. package/dist/editor/container-ops.d.ts +74 -0
  118. package/dist/editor/container-ops.d.ts.map +1 -0
  119. package/dist/editor/container-ops.js +262 -0
  120. package/dist/editor/container-ops.js.map +1 -0
  121. package/dist/editor/event-fanout.d.ts +48 -0
  122. package/dist/editor/event-fanout.d.ts.map +1 -0
  123. package/dist/editor/event-fanout.js +53 -0
  124. package/dist/editor/event-fanout.js.map +1 -0
  125. package/dist/editor/gesture-tx.d.ts +77 -0
  126. package/dist/editor/gesture-tx.d.ts.map +1 -0
  127. package/dist/editor/gesture-tx.js +93 -0
  128. package/dist/editor/gesture-tx.js.map +1 -0
  129. package/dist/editor/hit-test.d.ts +178 -0
  130. package/dist/editor/hit-test.d.ts.map +1 -0
  131. package/dist/editor/hit-test.js +460 -0
  132. package/dist/editor/hit-test.js.map +1 -0
  133. package/dist/editor/long-press.d.ts +34 -0
  134. package/dist/editor/long-press.d.ts.map +1 -0
  135. package/dist/editor/long-press.js +58 -0
  136. package/dist/editor/long-press.js.map +1 -0
  137. package/dist/editor/pinch.d.ts +37 -0
  138. package/dist/editor/pinch.d.ts.map +1 -0
  139. package/dist/editor/pinch.js +90 -0
  140. package/dist/editor/pinch.js.map +1 -0
  141. package/dist/editor/pointer-binding.d.ts +18 -0
  142. package/dist/editor/pointer-binding.d.ts.map +1 -0
  143. package/dist/editor/pointer-binding.js +1087 -0
  144. package/dist/editor/pointer-binding.js.map +1 -0
  145. package/dist/editor/public/annotations.d.ts +54 -0
  146. package/dist/editor/public/annotations.d.ts.map +1 -0
  147. package/dist/editor/public/annotations.js +106 -0
  148. package/dist/editor/public/annotations.js.map +1 -0
  149. package/dist/editor/public/arrange-group.d.ts +81 -0
  150. package/dist/editor/public/arrange-group.d.ts.map +1 -0
  151. package/dist/editor/public/arrange-group.js +193 -0
  152. package/dist/editor/public/arrange-group.js.map +1 -0
  153. package/dist/editor/public/brush.d.ts +33 -0
  154. package/dist/editor/public/brush.d.ts.map +1 -0
  155. package/dist/editor/public/brush.js +54 -0
  156. package/dist/editor/public/brush.js.map +1 -0
  157. package/dist/editor/public/clipboard.d.ts +31 -0
  158. package/dist/editor/public/clipboard.d.ts.map +1 -0
  159. package/dist/editor/public/clipboard.js +35 -0
  160. package/dist/editor/public/clipboard.js.map +1 -0
  161. package/dist/editor/public/image-insert.d.ts +30 -0
  162. package/dist/editor/public/image-insert.d.ts.map +1 -0
  163. package/dist/editor/public/image-insert.js +57 -0
  164. package/dist/editor/public/image-insert.js.map +1 -0
  165. package/dist/editor/public/layers.d.ts +56 -0
  166. package/dist/editor/public/layers.d.ts.map +1 -0
  167. package/dist/editor/public/layers.js +104 -0
  168. package/dist/editor/public/layers.js.map +1 -0
  169. package/dist/editor/public/link.d.ts +25 -0
  170. package/dist/editor/public/link.d.ts.map +1 -0
  171. package/dist/editor/public/link.js +74 -0
  172. package/dist/editor/public/link.js.map +1 -0
  173. package/dist/editor/public/placement.d.ts +60 -0
  174. package/dist/editor/public/placement.d.ts.map +1 -0
  175. package/dist/editor/public/placement.js +111 -0
  176. package/dist/editor/public/placement.js.map +1 -0
  177. package/dist/editor/public/selection-ops.d.ts +78 -0
  178. package/dist/editor/public/selection-ops.d.ts.map +1 -0
  179. package/dist/editor/public/selection-ops.js +226 -0
  180. package/dist/editor/public/selection-ops.js.map +1 -0
  181. package/dist/editor/public/text-edit.d.ts +17 -0
  182. package/dist/editor/public/text-edit.d.ts.map +1 -0
  183. package/dist/editor/public/text-edit.js +28 -0
  184. package/dist/editor/public/text-edit.js.map +1 -0
  185. package/dist/editor/public/z-order.d.ts +47 -0
  186. package/dist/editor/public/z-order.d.ts.map +1 -0
  187. package/dist/editor/public/z-order.js +151 -0
  188. package/dist/editor/public/z-order.js.map +1 -0
  189. package/dist/editor/public/zoom-pan.d.ts +44 -0
  190. package/dist/editor/public/zoom-pan.d.ts.map +1 -0
  191. package/dist/editor/public/zoom-pan.js +116 -0
  192. package/dist/editor/public/zoom-pan.js.map +1 -0
  193. package/dist/editor/render-orchestrator.d.ts +11 -0
  194. package/dist/editor/render-orchestrator.d.ts.map +1 -0
  195. package/dist/editor/render-orchestrator.js +473 -0
  196. package/dist/editor/render-orchestrator.js.map +1 -0
  197. package/dist/editor/resize-helpers.d.ts +30 -0
  198. package/dist/editor/resize-helpers.d.ts.map +1 -0
  199. package/dist/editor/resize-helpers.js +121 -0
  200. package/dist/editor/resize-helpers.js.map +1 -0
  201. package/dist/editor/shape-filters.d.ts +11 -0
  202. package/dist/editor/shape-filters.d.ts.map +1 -0
  203. package/dist/editor/shape-filters.js +19 -0
  204. package/dist/editor/shape-filters.js.map +1 -0
  205. package/dist/editor/shape-traits.d.ts +16 -0
  206. package/dist/editor/shape-traits.d.ts.map +1 -0
  207. package/dist/editor/shape-traits.js +29 -0
  208. package/dist/editor/shape-traits.js.map +1 -0
  209. package/dist/editor/viewport-helpers.d.ts +24 -0
  210. package/dist/editor/viewport-helpers.d.ts.map +1 -0
  211. package/dist/editor/viewport-helpers.js +63 -0
  212. package/dist/editor/viewport-helpers.js.map +1 -0
  213. package/dist/editor-events.d.ts +33 -0
  214. package/dist/editor-events.d.ts.map +1 -0
  215. package/dist/editor-events.js +2 -0
  216. package/dist/editor-events.js.map +1 -0
  217. package/dist/editor.d.ts +1841 -0
  218. package/dist/editor.d.ts.map +1 -0
  219. package/dist/editor.js +4803 -0
  220. package/dist/editor.js.map +1 -0
  221. package/dist/file-drop.d.ts +99 -0
  222. package/dist/file-drop.d.ts.map +1 -0
  223. package/dist/file-drop.js +198 -0
  224. package/dist/file-drop.js.map +1 -0
  225. package/dist/frame-helpers.d.ts +30 -0
  226. package/dist/frame-helpers.d.ts.map +1 -0
  227. package/dist/frame-helpers.js +105 -0
  228. package/dist/frame-helpers.js.map +1 -0
  229. package/dist/group-helpers.d.ts +39 -0
  230. package/dist/group-helpers.d.ts.map +1 -0
  231. package/dist/group-helpers.js +116 -0
  232. package/dist/group-helpers.js.map +1 -0
  233. package/dist/handle.d.ts +73 -0
  234. package/dist/handle.d.ts.map +1 -0
  235. package/dist/handle.js +184 -0
  236. package/dist/handle.js.map +1 -0
  237. package/dist/index.d.ts +26 -0
  238. package/dist/index.d.ts.map +1 -0
  239. package/dist/index.js +16 -0
  240. package/dist/index.js.map +1 -0
  241. package/dist/interactive.d.ts +21 -0
  242. package/dist/interactive.d.ts.map +1 -0
  243. package/dist/interactive.js +11 -0
  244. package/dist/interactive.js.map +1 -0
  245. package/dist/link-selection.d.ts +21 -0
  246. package/dist/link-selection.d.ts.map +1 -0
  247. package/dist/link-selection.js +37 -0
  248. package/dist/link-selection.js.map +1 -0
  249. package/dist/machine.d.ts +322 -0
  250. package/dist/machine.d.ts.map +1 -0
  251. package/dist/machine.js +568 -0
  252. package/dist/machine.js.map +1 -0
  253. package/dist/modes.d.ts +24 -0
  254. package/dist/modes.d.ts.map +1 -0
  255. package/dist/modes.js +2 -0
  256. package/dist/modes.js.map +1 -0
  257. package/dist/overlay.d.ts +262 -0
  258. package/dist/overlay.d.ts.map +1 -0
  259. package/dist/overlay.js +673 -0
  260. package/dist/overlay.js.map +1 -0
  261. package/dist/platform.d.ts +70 -0
  262. package/dist/platform.d.ts.map +1 -0
  263. package/dist/platform.js +115 -0
  264. package/dist/platform.js.map +1 -0
  265. package/dist/selection.d.ts +18 -0
  266. package/dist/selection.d.ts.map +1 -0
  267. package/dist/selection.js +31 -0
  268. package/dist/selection.js.map +1 -0
  269. package/package.json +59 -0
package/dist/editor.js ADDED
@@ -0,0 +1,4803 @@
1
+ import { createActor } from "xstate";
2
+ import { createEmitter } from "@oh-just-another/events";
3
+ import { elementId as castElementId } from "@oh-just-another/types";
4
+ import { addElement, anchorSnapper, apply, buildSpatialIndex, getBinaryFile, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, findNearestAnchor, getAnchorWorld, getAnchorOutwardNormal, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementWorldBounds, getElementRenderBounds, isFrame, FRAME_HEADER_HEIGHT, setTextMeasurer, getScreenToWorld, gridSnapper, snapExcludedAnchors, orderForTop, outlineSnapper, removeElement, SnapEngine, isNoop, invert, updateLink, updateElement, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
5
+ import { layerId as castLayerId, } from "@oh-just-another/types";
6
+ import { bounds as B, matrix } from "@oh-just-another/math";
7
+ import { caretGeometry, computeLinkWorldBounds, layoutText, onAnimationContentReady, pointToCaretIndex, selectionRects as textSelectionRects, setActiveRasterizer, setActiveTextShaper, setAnimationClock, ElementCache, } from "@oh-just-another/renderer-core";
8
+ import { History, } from "@oh-just-another/history";
9
+ import { ANCHOR_CLICK_NEW_ELEMENT_GAP, DEFAULT_LINK_ROUTING, WAYPOINT_COLLAPSE_RADIUS, } from "./constants.js";
10
+ import { FileDropRegistry } from "./file-drop.js";
11
+ import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
12
+ import { AnimationTick } from "./animation-tick.js";
13
+ import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
14
+ import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
15
+ import { AutoCompactScheduler } from "./auto-compact.js";
16
+ import { AutoLayoutScheduler } from "./auto-layout-scheduler.js";
17
+ import { DEFAULT_SNAP_THRESHOLD, LINK_ENDPOINT_HANDLE_RADIUS, LINK_HIT_THRESHOLD, LARGE_SCENE_HIT_THRESHOLD, TOUCH_LINK_HANDLE_HIT_SLOP, TOUCH_LINK_HIT_THRESHOLD, TOUCH_HANDLE_HIT_SLOP, ANCHOR_START_HIT_SLOP, ANCHOR_DOT_CLICK_RADIUS, ANCHOR_DOT_ACTIVE_RADIUS, LINK_START_ANCHOR_OUTSET, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, DOUBLE_CLICK_MS, DOUBLE_CLICK_TOLERANCE_PX, WHEEL_ZOOM_STEP, ANIMATION_MIN_INTERVAL_MS, ANIMATION_MAX_INTERVAL_MS, ANIMATION_COST_FACTOR, HEAVY_GIF_BYTES, GIF_AUTOSTOP_MS, CARET_BLINK_INTERVAL_MS, } from "./constants.js";
18
+ import { HANDLE_HIT_SLOP, cursorForHandle } from "./handle.js";
19
+ import { anchorOverlayPoints } from "./editor/anchor-points.js";
20
+ import { interactionMachine, } from "./machine.js";
21
+ import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
22
+ import { GestureController } from "./editor/gesture-tx.js";
23
+ import { LongPressController } from "./editor/long-press.js";
24
+ import { pickPressTarget } from "./editor/hit-test.js";
25
+ import { PinchController } from "./editor/pinch.js";
26
+ import { applyContainerDrop as applyContainerDropPure, clampContainerToChildren as clampContainerToChildrenPure, maybeGrowContainer as maybeGrowContainerPure, } from "./editor/container-ops.js";
27
+ import { computeGroupResizePatches, computeElementResize, computeTextResize, } from "./editor/applies/resize.js";
28
+ import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
29
+ import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, newBrushId, } from "./editor/public/brush.js";
30
+ import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
31
+ import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, computeRenameLayer, computeToggleLayerLock, computeToggleLayerVisibility, newLayerId, } from "./editor/public/layers.js";
32
+ import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, } from "./editor/public/zoom-pan.js";
33
+ import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
34
+ import { canBeginTextEdit } from "./editor/public/text-edit.js";
35
+ import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
36
+ import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
37
+ import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
38
+ import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, selectionFromNewIds, } from "./editor/public/selection-ops.js";
39
+ import { computeSetLink, normalizeHref, safeHref } from "./editor/public/link.js";
40
+ import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, newElementIdAtCursor, } from "./editor/public/placement.js";
41
+ import { renderEditor } from "./editor/render-orchestrator.js";
42
+ import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
43
+ import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
44
+ import { selectByBounds as selectByBoundsPure, selectByBoundsLive as selectByBoundsLivePure, selectLinksByBoundsLive as selectLinksByBoundsLivePure, } from "./editor/applies/selection.js";
45
+ import { computeLinkEndpointUpdate, computeLinkPreviewEndpoints } from "./editor/applies/edge.js";
46
+ import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, } from "./editor/applies/move.js";
47
+ import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
48
+ import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
49
+ import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
50
+ import {} from "./overlay.js";
51
+ import * as Selection from "./selection.js";
52
+ import * as LinkSelection from "./link-selection.js";
53
+ /**
54
+ * Index-access helper for provably-valid indices: throws instead of
55
+ * returning `undefined` so callers stay non-nullable without `!`.
56
+ */
57
+ const req = (v) => {
58
+ if (v === undefined)
59
+ throw new Error("packages/state: index out of range");
60
+ return v;
61
+ };
62
+ /** Resize handle → cursor override role. */
63
+ const RESIZE_ROLE = {
64
+ nw: "resize-nwse",
65
+ se: "resize-nwse",
66
+ ne: "resize-nesw",
67
+ sw: "resize-nesw",
68
+ n: "resize-ns",
69
+ s: "resize-ns",
70
+ e: "resize-ew",
71
+ w: "resize-ew",
72
+ };
73
+ /** Build a CSS `cursor` value from a {@link CursorSpec}. */
74
+ const cssCursor = (spec, fallbackKeyword) => {
75
+ if (typeof spec === "string")
76
+ return spec;
77
+ const hx = spec.hotspot?.x ?? 0;
78
+ const hy = spec.hotspot?.y ?? 0;
79
+ const img = spec.url2x !== undefined
80
+ ? `image-set(url("${spec.url}") 1x, url("${spec.url2x}") 2x)`
81
+ : `url("${spec.url}")`;
82
+ return `${img} ${String(hx)} ${String(hy)}, ${spec.fallback ?? fallbackKeyword}`;
83
+ };
84
+ export class Editor {
85
+ host;
86
+ mainTarget;
87
+ overlayTarget;
88
+ backgroundTarget;
89
+ onAfterRender;
90
+ /**
91
+ * Debug: when true the overlay paints every element's mouse hit-zones
92
+ * (handle slop / edge endpoint / edge body). Toggled by the host
93
+ * debug panel via `setDebugHitZones`; read by the render orchestrator.
94
+ * View-only — never persisted or recorded in history.
95
+ */
96
+ debugHitZones = false;
97
+ /**
98
+ * When false the background grid is not painted. Toggled via `toggleGrid`
99
+ * (`g` hotkey, standard parity). View-only — never persisted or in history.
100
+ */
101
+ gridVisible = true;
102
+ actor;
103
+ listeners = new Set();
104
+ /**
105
+ * Typed event surface. Specific events (`mode`, `selection`,
106
+ * `scene`, `history`, `viewport`) fan out of `notify()` based on
107
+ * what actually changed since the last fire, so subscribers only
108
+ * wake up when their slice flips. `change` still fires once per
109
+ * `notify()` for callers that don't care which slice.
110
+ *
111
+ * The `subscribe()` set runs in lockstep with the typed events.
112
+ */
113
+ events = createEmitter();
114
+ /**
115
+ * Last-emitted snapshot of every observable slice. Used by
116
+ * `fanOutEvents` (in `editor/event-fanout.ts`) to decide which
117
+ * typed events to fire on each `notify()` — only the slices
118
+ * whose identity changed since the previous notify get an event.
119
+ */
120
+ eventCache = createEventCache();
121
+ unbind;
122
+ _scene;
123
+ _selection = Selection.EMPTY;
124
+ /**
125
+ * Snapshot of an in-progress annotation drag (press on pin → move
126
+ * pointer → release). `originPosition` is the annotation's stored
127
+ * position at press time; per-move handler computes a delta from
128
+ * the current pointer in world space and writes it back.
129
+ */
130
+ annotationDrag = null;
131
+ /** Live preview while drawing a new shape; null when not drawing. */
132
+ drawingPreview = null;
133
+ edgePreview = null;
134
+ /**
135
+ * Active "drag a link from a start-anchor" gesture. Set when a
136
+ * press lands on one of the selected element's link-start dots; lets
137
+ * the user draw a link straight from the dot without switching to the
138
+ * draw-edge tool. `fromWorld` is the true anchor world point (the link
139
+ * origin, un-offset); `origin` is the press point (for the drag
140
+ * threshold). Read by the pointer handlers (drive preview / commit on
141
+ * up) and the render orchestrator (keep the source's start dots visible
142
+ * during the drag). Null when no such drag is in flight. */
143
+ linkDragFromAnchor = null;
144
+ /**
145
+ * Element being hovered while draw-edge mode is active. Drives the port-
146
+ * overlay render so the user sees attachment points. `null` outside
147
+ * draw-edge mode or when the pointer is over empty canvas.
148
+ */
149
+ hoveredLinkTarget = null;
150
+ /**
151
+ * Last idle cursor position (world) in select mode — the overlay grows the
152
+ * SINGLE selected element's link-start dot nearest it
153
+ * (`ANCHOR_DOT_HOVER_GROW_RADIUS`). Reset to null on press / gesture.
154
+ */
155
+ hoverCursorWorld = null;
156
+ /**
157
+ * When a link is dropped on empty canvas, the edge is created with a
158
+ * free `point` end and this records where, so the host can pop a
159
+ * mini shape-picker at that spot (standard). Picking a shape re-points the
160
+ * end to the new element; dismissing (Esc / click-away) leaves the free
161
+ * end on the canvas. `null` when no menu is pending.
162
+ */
163
+ pendingLinkDropMenu = null;
164
+ /**
165
+ * Currently selected links (connectors). Links are first-class members
166
+ * of the selection: they coexist with selected elements, join Cmd+A and
167
+ * marquee, and multi-select via Shift-click. Endpoint drag handles show
168
+ * only when EXACTLY one link is selected and no elements are (see
169
+ * `selectedLink`). Empty set = no link selected.
170
+ */
171
+ _selectedLinks = LinkSelection.EMPTY;
172
+ /**
173
+ * Currently focused annotation thread — overlay highlights its pin
174
+ * with an accent ring and hosts (e.g. `<CommentsPopover>`) render
175
+ * the thread for this id. Independent of shape / edge selection so
176
+ * users can edit shapes while a comment thread is open.
177
+ */
178
+ _selectedAnnotation = null;
179
+ /**
180
+ * Author identity used for comments posted via `addComment` /
181
+ * `addAnnotation` without an explicit author. Hosts set this once
182
+ * (typically from the same user object passed to `bindAwareness`).
183
+ * Defaults to a synthetic local user.
184
+ */
185
+ commentAuthor = { id: "local", name: "You" };
186
+ /**
187
+ * Mid-drag preview state when the user is dragging an edge endpoint.
188
+ * Drawn as an overlay line + handle dot so the user sees the target.
189
+ */
190
+ linkEndpointDrag = null;
191
+ /**
192
+ * Host-managed waypoint (bend-point) drag of the selected link. `index`
193
+ * is the position in `edge.waypoints`. `pendingInsert` means the gesture
194
+ * began on a segment midpoint and will splice a new waypoint on the
195
+ * first move (so a no-move click adds nothing). Live-mutated through the
196
+ * gesture transaction → one undo step per drag.
197
+ */
198
+ linkWaypointDrag = null;
199
+ /**
200
+ * Host-managed elbow segment drag. `index` is the segment in the routed
201
+ * chain `[from, ...routedPoints, to]`; `axis` is its orientation. Dragging
202
+ * pins the segment's perpendicular coordinate into `Link.fixedSegments`;
203
+ * the reroute pass re-flows the rest. One undo step via the gesture tx.
204
+ */
205
+ linkSegmentDrag = null;
206
+ /** Live lasso bounds during a rubber-band select gesture. */
207
+ lassoPreview = null;
208
+ /**
209
+ * Selection captured at lasso-press time. Used to compute the live
210
+ * preview correctly: in `replace` mode the lasso starts from empty
211
+ * each frame; in `add` mode it starts from this snapshot so shapes
212
+ * the user already had selected don't blink out and back.
213
+ */
214
+ lassoBaseSelection = null;
215
+ /** Link-selection counterpart of `lassoBaseSelection` for the marquee. */
216
+ lassoBaseLinks = null;
217
+ /**
218
+ * Snapshot of every selected shape's `position` at press-down. Used to
219
+ * translate the whole group additively during a multi-shape drag. The
220
+ * machine still emits per-shape MOVE_SHAPE — the editor intercepts and
221
+ * fans out when this map is populated.
222
+ */
223
+ groupMoveOrigin = null;
224
+ /**
225
+ * Press-time snapshot of connectors that must follow a multi-element
226
+ * drag rigidly — both endpoints bound to moved elements, carrying
227
+ * absolute geometry (waypoints / fixedSegments / routedPoints). Each
228
+ * frame translates from these originals so the shift never compounds.
229
+ * Cleared on gesture commit / cancel alongside `groupMoveOrigin`.
230
+ */
231
+ groupLinkMoveOrigin = null;
232
+ /**
233
+ * Per-shape snapshot for a group-resize gesture — `bounds` is the
234
+ * shape's world AABB at press-down. Editor scales the relative
235
+ * position / size against the combined bounds delta each frame.
236
+ */
237
+ groupResizeOrigin = null;
238
+ /**
239
+ * Pristine shape snapshot for a single-shape text resize, captured on
240
+ * the gesture's first tick. Font scaling is computed against this base
241
+ * so it never compounds across pointermove ticks. Cleared on gesture
242
+ * end (commit / cancel).
243
+ */
244
+ _resizeOriginElement = null;
245
+ /**
246
+ * Active layer — new shapes created via `addElement` / `applyCreate` land
247
+ * here when their input doesn't specify a `layerId`. Defaults to the
248
+ * scene's `DEFAULT_LAYER_ID`; hosts switch via `setActiveLayer`.
249
+ */
250
+ _activeLayerId = castLayerId(DEFAULT_LAYER_ID);
251
+ nextId = 0;
252
+ /** Generate a short unique id with a stable prefix. */
253
+ uniqueId(prefix) {
254
+ return `${prefix}-${++this.nextId}-${Date.now().toString(36)}`;
255
+ }
256
+ /**
257
+ * Snap engine — defaults to grid + anchor + outline contributors.
258
+ */
259
+ snapEngine = new SnapEngine([
260
+ gridSnapper,
261
+ anchorSnapper,
262
+ outlineSnapper,
263
+ ]);
264
+ /** Snap threshold in world units. */
265
+ snapThreshold = DEFAULT_SNAP_THRESHOLD;
266
+ /**
267
+ * Transient flag set by the host while a snap-suppress modifier
268
+ * (Cmd / Ctrl) is held during a drag — lets the user pull a shape off
269
+ * the grid for one gesture without toggling snap off. Read by the
270
+ * move / resize / create wrappers; never persisted.
271
+ */
272
+ snapSuppressed = false;
273
+ /**
274
+ * Persistent world-bounds cache shared with `renderScene` for viewport
275
+ * culling. Object-identity keyed — invalidates automatically whenever
276
+ * a scene op replaces the shape ref. Could be exposed for hit-test
277
+ * sharing in a follow-up.
278
+ */
279
+ boundsCache = new ElementCache();
280
+ /**
281
+ * Lazy SpatialGrid for hit-test acceleration in large scenes.
282
+ * Built on demand when `scene.elements.size >= LARGE_SCENE_HIT_THRESHOLD`
283
+ * and the cached index's source-scene reference is stale (any scene
284
+ * op replaces the `_scene` field, invalidating identity).
285
+ */
286
+ spatialIndexCache = null;
287
+ /**
288
+ * The group the user has "entered" via double-click. While set, the
289
+ * hit-test stops promoting children of this group to the group root,
290
+ * letting the user directly manipulate inner shapes. Cleared on
291
+ * escape, click outside the group's descendants, or `cancelInteraction`.
292
+ */
293
+ _enteredGroup = null;
294
+ /**
295
+ * Double-click detection state. Updated on every non-drag pointer
296
+ * up; the next pointer-up within `DOUBLE_CLICK_MS` and within
297
+ * `DOUBLE_CLICK_TOLERANCE_PX` of `lastClickWorldPoint` counts as a
298
+ * double-click. Used to trigger group drill-down (enter isolation).
299
+ */
300
+ lastClickAt = 0;
301
+ lastClickWorldPoint = null;
302
+ /**
303
+ * Separate double-click tracker for link edit handles (waypoint /
304
+ * segment). Kept apart from `lastClickAt` because a handle press
305
+ * returns early in `onDown` (begin-drag) and never reaches the up-side
306
+ * double-click path that updates `lastClickAt`. Updated by
307
+ * `isHandleDoubleClick` on each handle press.
308
+ */
309
+ lastHandleClickAt = 0;
310
+ lastHandleClickWorld = null;
311
+ /**
312
+ * In-progress brush stroke. Hosts push points via
313
+ * `extendBrushStroke`; the overlay reads it through
314
+ * `pendingBrushStroke` to draw a live preview.
315
+ */
316
+ brushStroke = null;
317
+ /**
318
+ * Last world-space pointer position observed by the host's onMove
319
+ * handler. `paste()` uses it as the default drop target so a fresh
320
+ * paste lands under the cursor instead of overlapping the originals.
321
+ * `null` until the pointer first enters the host.
322
+ */
323
+ lastPointerWorld = null;
324
+ /** Host-registered custom cursor images per role (see `setCursorOverride`). */
325
+ cursorOverrides = new Map();
326
+ /**
327
+ * Scene rendered on the last frame. Used to compute a dirty rect by
328
+ * identity-diffing against the current scene — every shape / edge
329
+ * whose ref didn't change is also pixel-identical to its last paint
330
+ * and gets skipped together with the surrounding clear. `null` until
331
+ * the first render.
332
+ */
333
+ lastRenderedScene = null;
334
+ /**
335
+ * Last-painted isolation root — paired with `lastRenderedScene` so
336
+ * the dirty-rect optimization invalidates when the user enters or
337
+ * exits a group, even when the scene reference is unchanged. Without
338
+ * this, drilling into a group never triggers a redraw → the dim
339
+ * pass would never visibly apply.
340
+ */
341
+ lastRenderedEnteredGroup = null;
342
+ /**
343
+ * Fractional-order compaction scheduler (microtask-coalesced).
344
+ * Triggered from every `notify()`; only does real work when at
345
+ * least one shape/edge order string crossed AUTO_COMPACT_THRESHOLD.
346
+ * See `./auto-compact.ts` for the extracted logic.
347
+ */
348
+ autoCompactScheduler = new AutoCompactScheduler({
349
+ getScene: () => this._scene,
350
+ compact: (layerId) => {
351
+ this.compactLayerZOrder(layerId, { recordHistory: false });
352
+ },
353
+ });
354
+ /**
355
+ * Auto-layout scheduler — microtask-coalesced re-run of every
356
+ * shape carrying `metadata.autoLayout`. See
357
+ * `./auto-layout-scheduler.ts` for the extracted logic.
358
+ */
359
+ autoLayoutScheduler = new AutoLayoutScheduler({
360
+ getScene: () => this._scene,
361
+ applyPatch: (patch) => {
362
+ this._scene = apply(this._scene, patch);
363
+ if (this.gestureTx)
364
+ this.gestureTx.add(patch);
365
+ else
366
+ this._history.push(patch);
367
+ },
368
+ growContainer: (parentId, childId) => {
369
+ this.maybeGrowContainer(parentId, childId);
370
+ },
371
+ onMutated: () => {
372
+ // Re-render only; do NOT call notify() — that would re-schedule
373
+ // the check and risk a microtask loop. Listeners already saw
374
+ // the previous notify; the auto-layout adjustment is a
375
+ // synchronous fix-up on top of the same external event.
376
+ this.scheduleRender();
377
+ for (const fn of this.listeners)
378
+ fn();
379
+ },
380
+ });
381
+ /**
382
+ * Element id that the user started dragging on press-down. Tracked
383
+ * separately from the state machine so the editor knows what to
384
+ * (re)parent / drop into a container on pointerup. `null` between
385
+ * gestures, set in onDown when press lands on a shape and cleared
386
+ * in onUp / cancel.
387
+ */
388
+ dragElementId = null;
389
+ /**
390
+ * Element that the current press added to the selection additively
391
+ * (shift / meta click on an unselected shape). The press promotes it
392
+ * so a subsequent drag moves it; on a *tap* the up-handler would
393
+ * otherwise `SELECT_TOGGLE` it straight back off, so it consults this
394
+ * to skip that redundant toggle. Reset at every press-down.
395
+ */
396
+ additivePressAdded = null;
397
+ /**
398
+ * Live container highlight: the container shape the dragged item is
399
+ * currently hovering over. Drawn by the overlay as a dashed
400
+ * accent rect on the container's drop-zone so the user sees where the
401
+ * shape will land after release.
402
+ */
403
+ containerHover = null;
404
+ /**
405
+ * Remote peer cursors / selections, pushed in by the host (typically
406
+ * a `bindAwareness(editor, awareness)` helper in `@collab`). The
407
+ * editor only renders them; it doesn't fetch or interpret. Each
408
+ * setter triggers `render()` so the overlay updates immediately.
409
+ */
410
+ _peerCursors = [];
411
+ _peerSelections = [];
412
+ /**
413
+ * Subscribers notified on every host pointer move (world-space). Used
414
+ * by `@collab` to broadcast the local cursor into awareness.
415
+ */
416
+ cursorListeners = new Set();
417
+ /**
418
+ * Active screen-space pointer positions keyed by `pointerId`. With
419
+ * one entry the editor's normal single-pointer flow applies. With
420
+ * two or more entries we enter a pinch / pan gesture and bypass the
421
+ * interaction machine — `pinchOrigin` holds the baseline.
422
+ */
423
+ activePointers = new Map();
424
+ /**
425
+ * One-finger-pan candidate: set at pointer-down when a TOUCH press lands
426
+ * on empty canvas in select mode. A tap (no movement) still falls through
427
+ * to select/deselect; once the finger drags past slop, onMove promotes
428
+ * this to a real pan instead of a marquee lasso (mobile convention).
429
+ * Screen-space origin point.
430
+ */
431
+ touchPanCandidate = null;
432
+ // Pinch gesture state lives in PinchController (./editor/pinch.ts)
433
+ // — `pinch.isActive()` replaces the old `pinchOrigin !== null` check.
434
+ pinch;
435
+ /** Bridge for `editor/container-ops.ts`. Built lazily in constructor. */
436
+ containerOpsRef;
437
+ /**
438
+ * Space-bar held → next pointer drag pans the canvas instead of
439
+ * doing whatever the current mode would do. Visual cursor goes to
440
+ * "grab" / "grabbing". Wires a window-level keydown/keyup listener
441
+ * in `bindPointerEvents`.
442
+ */
443
+ spaceHeld = false;
444
+ /**
445
+ * Host-supplied tile compositor — when set (via
446
+ * `EditorOptions.useTileCache` + `tileCompose`), the per-frame
447
+ * render path delegates to it instead of `renderScene`. Stays
448
+ * `null` for the typical small-scene case.
449
+ */
450
+ tileComposeFn;
451
+ /**
452
+ * Per-shape change record (before/after world bbox) since the last
453
+ * tile-cache invalidation pass. Populated by `computeDirtyWorld`'s
454
+ * diff loop when `tileComposeFn` is on; forwarded to the compositor
455
+ * each frame so it can invalidate by add / remove / move correctly.
456
+ * (A plain id set lost adds — new id wasn't in the tile reverse
457
+ * index yet.)
458
+ */
459
+ tileDirtyElements = new Map();
460
+ /**
461
+ * Tool-lock flag (standard model). When `false` (default), a
462
+ * draw-mode (`draw-rect` / `draw-ellipse` / `draw-edge` / `brush`)
463
+ * auto-reverts to `select` after a successful create. When `true`,
464
+ * the mode persists so the user can draw many shapes in a row
465
+ * without re-selecting the tool.
466
+ *
467
+ * Toggled via `Editor.setToolLocked(bool)` and surfaced in the
468
+ * toolbar as a lock affordance next to the active tool.
469
+ */
470
+ _toolLocked = false;
471
+ /**
472
+ * Host-extensible file-drop dispatch. Built-ins (image / scene
473
+ * JSON) register themselves at editor construction; hosts add
474
+ * more via `registerFileDropHandler`.
475
+ */
476
+ fileDropRegistry = new FileDropRegistry();
477
+ /**
478
+ * Active pan gesture (right-click drag or Space + left drag).
479
+ * `pointerId` is captured by the host so move/up events keep
480
+ * arriving even after the cursor leaves the host bounds.
481
+ * `startPoint` is the press position — used to decide "click vs
482
+ * drag" at pointerup (a near-zero displacement right-click is a
483
+ * context-menu request, not a pan). `lastPoint` tracks the
484
+ * previous move so per-frame delta is correct.
485
+ *
486
+ * `button` records which mouse button started the gesture so we
487
+ * only treat right-click releases as potential context-menu
488
+ * triggers (Space + left-drag never opens a menu).
489
+ */
490
+ panGesture = null;
491
+ /**
492
+ * Set on a right-click pointerdown so the upcoming native
493
+ * `contextmenu` event can be unconditionally preventDefault'ed
494
+ * (the gesture decides whether to fire the menu manually on
495
+ * pointerup based on whether the user dragged).
496
+ */
497
+ suppressNextContextMenu = false;
498
+ /**
499
+ * Long-press tracking. Starts on `pointerdown`; cancelled on
500
+ * `pointermove > LONG_PRESS_MAX_MOVEMENT_PX` or `pointerup` before
501
+ * the timer fires. Hosts subscribe via `onLongPress` to surface a
502
+ * context menu (mobile alternative to right-click).
503
+ */
504
+ // Long-press timer + origin live in LongPressController
505
+ // (./editor/long-press.ts). The Set of subscribers stays here
506
+ // because `onLongPress` is part of the public Editor API.
507
+ longPress;
508
+ longPressListeners = new Set();
509
+ /**
510
+ * Live-region announcements for assistive tech. The editor pushes
511
+ * short, human-readable strings ("Selected Rectangle", "Moved 5 px
512
+ * right") that hosts pipe into an `aria-live=polite` region.
513
+ */
514
+ announceListeners = new Set();
515
+ /**
516
+ * Resolved primary input modality + derived hit slops. Computed once
517
+ * in the constructor from `EditorOptions.inputMode` (default `"auto"`
518
+ * uses `matchMedia('(pointer: coarse)')`).
519
+ */
520
+ inputMode;
521
+ handleHitSlop;
522
+ edgeHandleHitSlop;
523
+ edgeHitThreshold;
524
+ /** Link-start anchor-dot grab/click hit radii — touch-enlarged in touch mode. */
525
+ anchorStartHitSlop;
526
+ anchorClickRadius;
527
+ _history;
528
+ /** Open transaction during a single drag/resize gesture. */
529
+ gestureTx = null;
530
+ /**
531
+ * Immutable snapshot of `_scene` taken when a gesture transaction opens.
532
+ * The history transaction only records patches for undo — cancelling it does
533
+ * NOT roll back `_scene`. Keeping the pre-gesture scene lets Escape (and any
534
+ * cancel) restore it, so a drag/move/resize/endpoint-rebind aborted with Esc
535
+ * leaves the scene exactly as it was. Cleared on commit.
536
+ */
537
+ gestureStartScene = null;
538
+ /**
539
+ * Wraps gesture lifecycle (transaction open/commit/cancel +
540
+ * post-create mode revert) so editor.ts doesn't carry the bodies.
541
+ * Implementation lives in `./editor/gesture-tx.ts`; the
542
+ * controller calls back through the narrow `GestureRef` bridge
543
+ * built lazily below.
544
+ */
545
+ gestures;
546
+ constructor(options) {
547
+ this.host = options.host;
548
+ this.mainTarget = options.mainTarget;
549
+ this.overlayTarget = options.overlayTarget;
550
+ this.backgroundTarget = options.backgroundTarget ?? null;
551
+ this.onAfterRender = options.onAfterRender ?? null;
552
+ this._scene = options.initialScene;
553
+ this._history = isHistoryProvider(options.history)
554
+ ? options.history
555
+ : new History(options.history ?? {});
556
+ // Build the gesture controller against a narrow getter/setter
557
+ // bridge to the editor's mutable state. The bridge is a thin
558
+ // adapter — keeps `gestureTx`/`dragElementId` etc. as `private`
559
+ // fields on Editor (instead of forcing them public to satisfy
560
+ // structural implements), and lets the controller live in its
561
+ // own module without importing Editor.
562
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- getters/setters in the literal rebind `this`; alias keeps Editor reference
563
+ const self = this;
564
+ this.gestures = new GestureController({
565
+ get history() {
566
+ return self._history;
567
+ },
568
+ get gestureTx() {
569
+ return self.gestureTx;
570
+ },
571
+ set gestureTx(v) {
572
+ self.gestureTx = v;
573
+ },
574
+ get groupMoveOrigin() {
575
+ return self.groupMoveOrigin;
576
+ },
577
+ set groupMoveOrigin(v) {
578
+ self.groupMoveOrigin = v;
579
+ },
580
+ get groupLinkMoveOrigin() {
581
+ return self.groupLinkMoveOrigin;
582
+ },
583
+ set groupLinkMoveOrigin(v) {
584
+ self.groupLinkMoveOrigin = v;
585
+ },
586
+ get groupResizeOrigin() {
587
+ return self.groupResizeOrigin;
588
+ },
589
+ set groupResizeOrigin(v) {
590
+ self.groupResizeOrigin = v;
591
+ },
592
+ get dragElementId() {
593
+ return self.dragElementId;
594
+ },
595
+ set dragElementId(v) {
596
+ self.dragElementId = v;
597
+ },
598
+ get containerHover() {
599
+ return self.containerHover;
600
+ },
601
+ set containerHover(v) {
602
+ self.containerHover = v;
603
+ },
604
+ get toolLocked() {
605
+ return self._toolLocked;
606
+ },
607
+ get mode() {
608
+ return self.mode;
609
+ },
610
+ setMode: (m) => {
611
+ self.setMode(m);
612
+ },
613
+ notify: () => {
614
+ self.notify();
615
+ },
616
+ });
617
+ this.tileComposeFn =
618
+ options.useTileCache === true && options.tileCompose ? options.tileCompose : null;
619
+ // If the host plugged a TextShaper, install it process-globally so the
620
+ // built-in text renderer's wrap path uses it instead of
621
+ // Canvas2D.measureText. Hosts that don't care leave the field unset and
622
+ // the default behaviour is unchanged.
623
+ if (options.textShaper)
624
+ setActiveTextShaper(options.textShaper);
625
+ // Same pattern for the Rasterizer. The WebGL2 backend reads
626
+ // `getActiveRasterizer()` from its curve methods and routes through WASM
627
+ // flatten / strokeToFill when set. Other backends (Canvas2D, SVG) leave
628
+ // the field alone — native ctx.bezierCurveTo beats any WASM round-trip
629
+ // there.
630
+ if (options.rasterizer)
631
+ setActiveRasterizer(options.rasterizer);
632
+ // Drive the scene text bounder from the renderer's own metrics so
633
+ // the selection box hugs the rendered text (the WebGL2 MSDF font's
634
+ // advances differ from any geometric estimate). Measuring sets the
635
+ // font on the main target — harmless, every draw re-sets its own.
636
+ setTextMeasurer((text, family, size, opts) => {
637
+ this.mainTarget.setFont(family, size, {
638
+ ...(opts?.bold ? { weight: "bold" } : {}),
639
+ ...(opts?.italic ? { style: "italic" } : {}),
640
+ });
641
+ return this.mainTarget.measureText(text).width;
642
+ });
643
+ // Resolve input mode + derived hit slops once. `auto` reads
644
+ // `matchMedia('(pointer: coarse)')` when available; SSR falls
645
+ // back to `mouse`.
646
+ const requested = options.inputMode ?? "auto";
647
+ if (requested === "touch") {
648
+ this.inputMode = "touch";
649
+ }
650
+ else if (requested === "mouse") {
651
+ this.inputMode = "mouse";
652
+ }
653
+ else if (typeof window !== "undefined" &&
654
+ typeof window.matchMedia === "function" &&
655
+ window.matchMedia("(pointer: coarse)").matches) {
656
+ this.inputMode = "touch";
657
+ }
658
+ else {
659
+ this.inputMode = "mouse";
660
+ }
661
+ this.handleHitSlop = this.inputMode === "touch" ? TOUCH_HANDLE_HIT_SLOP : HANDLE_HIT_SLOP;
662
+ this.edgeHandleHitSlop =
663
+ this.inputMode === "touch" ? TOUCH_LINK_HANDLE_HIT_SLOP : LINK_ENDPOINT_HANDLE_RADIUS;
664
+ this.edgeHitThreshold =
665
+ this.inputMode === "touch" ? TOUCH_LINK_HIT_THRESHOLD : LINK_HIT_THRESHOLD;
666
+ this.anchorStartHitSlop =
667
+ this.inputMode === "touch" ? TOUCH_ANCHOR_START_HIT_SLOP : ANCHOR_START_HIT_SLOP;
668
+ this.anchorClickRadius =
669
+ this.inputMode === "touch" ? TOUCH_ANCHOR_DOT_CLICK_RADIUS : ANCHOR_DOT_CLICK_RADIUS;
670
+ this.actor = createActor(interactionMachine);
671
+ this.actor.subscribe({
672
+ next: () => {
673
+ // Render on any state change so drawing rubber-band updates.
674
+ this.scheduleRender();
675
+ },
676
+ });
677
+ this.actor.on("*", (event) => {
678
+ this.applyEmit(event);
679
+ });
680
+ this.actor.start();
681
+ // Built-in file-drop handlers — registered before any host-side
682
+ // wiring so subsequent host `registerFileDropHandler` calls land
683
+ // *after* and only fire for files we don't already handle.
684
+ this.fileDropRegistry.register(imageFileDropHandler);
685
+ this.fileDropRegistry.register(videoFileDropHandler);
686
+ if (options.initialMode) {
687
+ this.actor.send({ type: "SET_MODE", mode: options.initialMode });
688
+ }
689
+ // Long-press controller — fired on touch-hold; fans out to
690
+ // host-registered listeners (mobile alt to right-click).
691
+ this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
692
+ for (const fn of this.longPressListeners)
693
+ fn(payload);
694
+ });
695
+ // Pinch gesture controller — two-finger pan + zoom. Hooks into
696
+ // the editor's own zoomAt / panBy / screenToWorld.
697
+ this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
698
+ this.zoomAt(factor, anchorWorld);
699
+ }, (delta) => {
700
+ this.panBy(delta);
701
+ });
702
+ // Bridge for container-ops module — narrow surface that the
703
+ // pure functions in editor/container-ops.ts call back into.
704
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- bridge literal rebinds `this`; alias keeps Editor reference
705
+ const self2 = this;
706
+ this.containerOpsRef = {
707
+ get scene() {
708
+ return self2._scene;
709
+ },
710
+ get dragElementId() {
711
+ return self2.dragElementId;
712
+ },
713
+ get containerHover() {
714
+ return self2.containerHover;
715
+ },
716
+ applyPatch(patch, nextScene) {
717
+ self2._scene = nextScene;
718
+ self2.beginOrAttachGesture().add(patch);
719
+ },
720
+ };
721
+ this.unbind = this.bindPointerEvents();
722
+ // Pause animation playback when the tab / window is hidden (browsers
723
+ // throttle rAF to ~1fps in background but don't stop it; an explicit
724
+ // stop saves the decode + render entirely). Resume when visible again,
725
+ // viewport permitting.
726
+ if (typeof document !== "undefined") {
727
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
728
+ }
729
+ // Restore GIF/video bytes onto animated image shapes loaded from
730
+ // an initial scene (e.g. localStorage), then arm the tick so the
731
+ // animation plays from first paint.
732
+ this.rehydrateAnimatedImages();
733
+ this.maybeAnimate();
734
+ // An animated adapter (GIF) decodes asynchronously; when a decode
735
+ // completes it nudges us here. Re-render so a PAUSED animated shape
736
+ // (reduced-motion / auto-stopped / frozen) — which has no tick to
737
+ // pick the frames up — paints its decoded frame after reload.
738
+ this.animationContentOff = onAnimationContentReady(() => {
739
+ this.scheduleRender();
740
+ });
741
+ // First paint — synchronous so the canvas isn't blank for one
742
+ // frame on mount. Hosts that mount + immediately read the
743
+ // bitmap also get a consistent first frame.
744
+ this.forceRender();
745
+ // Prime the typed-event cache with the editor's initial state so
746
+ // the *first* user-driven update only emits on a real flip.
747
+ // Without this, an `editor.on("mode", fn)` listener installed
748
+ // before any change would fire on the very next `setMode(current)`
749
+ // call because every cached slice would still be `null`.
750
+ primeEventCache(this.eventCache, this.observableSnapshot());
751
+ }
752
+ /** Snapshot used by event-fanout. Kept private — internal API. */
753
+ observableSnapshot() {
754
+ return {
755
+ mode: this.mode,
756
+ selection: this._selection,
757
+ selectedLinks: this._selectedLinks,
758
+ scene: this._scene,
759
+ canUndo: this.canUndo,
760
+ canRedo: this.canRedo,
761
+ };
762
+ }
763
+ // --- Public state ---
764
+ get scene() {
765
+ return this._scene;
766
+ }
767
+ get selection() {
768
+ return this._selection;
769
+ }
770
+ get mode() {
771
+ return this.actor.getSnapshot().context.mode;
772
+ }
773
+ get history() {
774
+ return this._history;
775
+ }
776
+ get canUndo() {
777
+ return this._history.canUndo;
778
+ }
779
+ get canRedo() {
780
+ return this._history.canRedo;
781
+ }
782
+ /**
783
+ * The DOM element the editor was mounted onto. Read-only — external
784
+ * code reads it for screen-↔-world coordinate conversions on events
785
+ * whose coordinates are in client-space (e.g. global `contextmenu`).
786
+ */
787
+ get hostElement() {
788
+ return this.host;
789
+ }
790
+ /** Subscribe to scene/selection/mode/history changes. */
791
+ subscribe(fn) {
792
+ this.listeners.add(fn);
793
+ return () => this.listeners.delete(fn);
794
+ }
795
+ // --- Collab: remote presence push + local cursor push ---
796
+ /**
797
+ * Subscribe to local world-space pointer movement. Fires on every
798
+ * `pointermove` over the host. `@collab` uses this to broadcast the
799
+ * local cursor into the awareness room.
800
+ */
801
+ onCursorMove(fn) {
802
+ this.cursorListeners.add(fn);
803
+ return () => this.cursorListeners.delete(fn);
804
+ }
805
+ // --- Annotations ---
806
+ /** Set the local user's identity for comments authored via this editor. */
807
+ setCommentAuthor(author) {
808
+ this.commentAuthor = author;
809
+ }
810
+ /** Currently focused annotation id (or null when nothing is open). */
811
+ get selectedAnnotation() {
812
+ return this._selectedAnnotation;
813
+ }
814
+ /**
815
+ * Open or close an annotation thread. `null` clears the focus. The
816
+ * overlay highlights the pin; `<CommentsPopover>` reads this and
817
+ * renders the thread.
818
+ */
819
+ setSelectedAnnotation(id) {
820
+ if (this._selectedAnnotation === id)
821
+ return;
822
+ this._selectedAnnotation = id;
823
+ this.notify();
824
+ }
825
+ // Pure bodies in `./editor/public/annotations.ts`.
826
+ addAnnotation(opts) {
827
+ const result = computeAddAnnotation(this._scene, opts, this.commentAuthor, (p) => this.uniqueId(p));
828
+ this._scene = result.scene;
829
+ this._history.push(result.patch);
830
+ this._selectedAnnotation = result.id;
831
+ this.notify();
832
+ this.announce("Annotation added");
833
+ return result.id;
834
+ }
835
+ removeAnnotation(id) {
836
+ const result = computeRemoveAnnotation(this._scene, id);
837
+ if (!result)
838
+ return;
839
+ this._scene = result.scene;
840
+ this._history.push(result.patch);
841
+ if (this._selectedAnnotation === id)
842
+ this._selectedAnnotation = null;
843
+ this.notify();
844
+ this.announce("Annotation removed");
845
+ }
846
+ toggleAnnotationResolved(id) {
847
+ const result = computeToggleAnnotationResolved(this._scene, id);
848
+ if (!result)
849
+ return;
850
+ this._scene = result.scene;
851
+ this._history.push(result.patch);
852
+ this.notify();
853
+ this.announce(result.wasResolved ? "Annotation reopened" : "Annotation resolved");
854
+ }
855
+ addComment(annotationId, body, author) {
856
+ const result = computeAddComment(this._scene, annotationId, body, author ?? this.commentAuthor, (p) => this.uniqueId(p));
857
+ if (!result)
858
+ return;
859
+ this._scene = result.scene;
860
+ this._history.push(result.patch);
861
+ this.notify();
862
+ }
863
+ removeComment(annotationId, commentId) {
864
+ const result = computeRemoveComment(this._scene, annotationId, commentId);
865
+ if (!result)
866
+ return;
867
+ this._scene = result.scene;
868
+ this._history.push(result.patch);
869
+ this.notify();
870
+ }
871
+ hitAnnotation(worldPoint) {
872
+ return hitAnnotationPure(this._scene, worldPoint);
873
+ }
874
+ /**
875
+ * Subscribe to long-press events — a stationary touch / mouse-press
876
+ * held longer than `LONG_PRESS_DELAY_MS`. The mobile-equivalent of
877
+ * right-click; hosts open a context menu from this. The event
878
+ * carries both screen-space (for menu positioning) and world-space
879
+ * (for target hit-test) coordinates.
880
+ */
881
+ onLongPress(fn) {
882
+ this.longPressListeners.add(fn);
883
+ return () => this.longPressListeners.delete(fn);
884
+ }
885
+ /**
886
+ * Subscribe to accessibility live-region announcements. The host
887
+ * pipes these strings into an `aria-live="polite"` element so a
888
+ * screen-reader user hears the editor's status changes. Strings
889
+ * are short and pre-localised by the caller of `announce`.
890
+ */
891
+ onAnnounce(fn) {
892
+ this.announceListeners.add(fn);
893
+ return () => this.announceListeners.delete(fn);
894
+ }
895
+ /**
896
+ * Push a live-region message to all `onAnnounce` listeners. Hosts
897
+ * (and plugins) call this when something happened that an SR user
898
+ * should hear: selection changed, shape moved, mode switched, etc.
899
+ * The editor itself emits a small set of canonical messages from
900
+ * `focusCycle` / `moveSelectionBy` / `cancelInteraction`.
901
+ */
902
+ announce(message) {
903
+ if (!message)
904
+ return;
905
+ for (const fn of this.announceListeners)
906
+ fn(message);
907
+ }
908
+ /**
909
+ * Replace the remote peer cursors painted by the overlay. Pass an
910
+ * empty array to clear. The host is expected to filter out the
911
+ * local user's cursor before calling.
912
+ */
913
+ setPeerCursors(cursors) {
914
+ this._peerCursors = cursors;
915
+ this.scheduleRender();
916
+ }
917
+ /**
918
+ * Replace the remote peer selections painted by the overlay. The
919
+ * host resolves a peer's `selection: ElementId[]` into world bounds
920
+ * before passing them in.
921
+ */
922
+ setPeerSelections(selections) {
923
+ this._peerSelections = selections;
924
+ this.scheduleRender();
925
+ }
926
+ /**
927
+ * Toggle the debug hit-zone overlay (host debug panel). When on, the
928
+ * overlay paints every element's mouse hit-targets so the tuned slop
929
+ * values can be eyeballed. View-only — not recorded in history.
930
+ */
931
+ setDebugHitZones(on) {
932
+ if (this.debugHitZones === on)
933
+ return;
934
+ this.debugHitZones = on;
935
+ this.scheduleRender();
936
+ }
937
+ /** Show/hide the background grid (standard `g`). View-only — not in history. */
938
+ setGridVisible(on) {
939
+ if (this.gridVisible === on)
940
+ return;
941
+ this.gridVisible = on;
942
+ this.scheduleRender();
943
+ }
944
+ /** Toggle background grid visibility. */
945
+ toggleGrid() {
946
+ this.setGridVisible(!this.gridVisible);
947
+ }
948
+ /** Whether the active draw-mode sticks after a create (toolbar lock). */
949
+ get toolLocked() {
950
+ return this._toolLocked;
951
+ }
952
+ /** All currently-selected link (connector) ids. */
953
+ get selectedLinks() {
954
+ return this._selectedLinks;
955
+ }
956
+ /**
957
+ * The sole selected link — non-null ONLY when exactly one link and no
958
+ * elements are selected. Drives endpoint handles, the edge-style panel,
959
+ * caption edit and `updateSelectedLink`; a multi/mixed selection yields
960
+ * null so those single-link affordances stay hidden.
961
+ */
962
+ get selectedLink() {
963
+ if (this._selection.size > 0)
964
+ return null;
965
+ return LinkSelection.sole(this._selectedLinks);
966
+ }
967
+ /**
968
+ * Apply an in-place mutation to the currently-selected edge as a
969
+ * single history step. The `updater` receives a clone of the edge
970
+ * and returns the next version (callers should produce a new
971
+ * object — Link is readonly). No-op when no edge is selected.
972
+ */
973
+ updateSelectedLink(updater) {
974
+ const id = this.selectedLink;
975
+ if (id === null)
976
+ return;
977
+ const r = updateLink(this._scene, id, updater);
978
+ this._scene = r.scene;
979
+ this._history.push(r.patch);
980
+ this.notify();
981
+ }
982
+ /**
983
+ * Register a file-drop handler. Handlers are tried in registration
984
+ * order; the first whose `accept(file)` returns true takes the
985
+ * file. Repeated calls with the same `id` replace the previous
986
+ * handler (idempotent for module-load wiring).
987
+ */
988
+ registerFileDropHandler(handler) {
989
+ this.fileDropRegistry.register(handler);
990
+ }
991
+ /** Drop a registered handler. */
992
+ unregisterFileDropHandler(id) {
993
+ this.fileDropRegistry.unregister(id);
994
+ }
995
+ /**
996
+ * Dispatch a dropped file (or pasted file from clipboard) into
997
+ * the registered handlers. Returns `true` when a handler accepted
998
+ * the file, `false` otherwise — UI can show an "unsupported file"
999
+ * toast on `false`. `worldPoint` is where the file should land
1000
+ * (drop-point projected to world coords; for paste, host can use
1001
+ * cursor world point or viewport centre).
1002
+ */
1003
+ async dispatchFileDrop(file, worldPoint) {
1004
+ const ctx = { editor: this, worldPoint };
1005
+ return this.fileDropRegistry.dispatch(file, ctx);
1006
+ }
1007
+ /**
1008
+ * Toggle the tool-lock affordance. With `true`, draw-modes persist
1009
+ * after each successful shape create — the user keeps drawing
1010
+ * rectangles without re-pressing R. With `false` (default), the
1011
+ * editor reverts to `select` after each create.
1012
+ */
1013
+ setToolLocked(locked) {
1014
+ if (this._toolLocked === locked)
1015
+ return;
1016
+ this._toolLocked = locked;
1017
+ this.notify();
1018
+ }
1019
+ // Body moved to `./editor/gesture-tx.ts`.
1020
+ maybeRevertModeAfterCreate() {
1021
+ this.gestures.maybeRevertModeAfterCreate();
1022
+ }
1023
+ setMode(mode) {
1024
+ // Switching tools commits any in-flight text edit (standard: leaving the
1025
+ // editing context ends it, keeping the typed text).
1026
+ if (this._editingTextElement !== null)
1027
+ this.commitTextEdit();
1028
+ // Cancel any in-progress drag gesture so the partial state is not recorded.
1029
+ if (this.gestureTx) {
1030
+ this.gestureTx.cancel();
1031
+ this.gestureTx = null;
1032
+ }
1033
+ // Hide the port overlay when leaving draw-edge.
1034
+ if (mode !== "draw-edge" && this.hoveredLinkTarget !== null) {
1035
+ this.hoveredLinkTarget = null;
1036
+ }
1037
+ this.actor.send({ type: "SET_MODE", mode });
1038
+ // Cursor affordance follows the new mode (hand → grab, draw tools →
1039
+ // crosshair, etc.) — recompute through the single chokepoint.
1040
+ this.refreshCursor();
1041
+ this.notify();
1042
+ }
1043
+ /** Undo the latest record. No-op if there is nothing to undo. */
1044
+ undo() {
1045
+ const inverse = this._history.undo();
1046
+ if (!inverse)
1047
+ return false;
1048
+ this._scene = apply(this._scene, inverse);
1049
+ this.pruneSelection();
1050
+ this.notify();
1051
+ return true;
1052
+ }
1053
+ /** Redo the undone record. */
1054
+ redo() {
1055
+ const patch = this._history.redo();
1056
+ if (!patch)
1057
+ return false;
1058
+ this._scene = apply(this._scene, patch);
1059
+ this.pruneSelection();
1060
+ this.notify();
1061
+ return true;
1062
+ }
1063
+ /**
1064
+ * Add a shape to the scene and push a single record onto the history stack.
1065
+ * Returns the resulting patch (useful for tests). Intended for code paths
1066
+ * that create shapes outside of a pointer gesture — drag-from-palette,
1067
+ * paste, programmatic insert.
1068
+ */
1069
+ addElement(shape, options) {
1070
+ const result = addElement(this._scene, shape);
1071
+ this._scene = result.scene;
1072
+ if (options?.select ?? true) {
1073
+ this._selection = Selection.single(shape.id);
1074
+ }
1075
+ this._history.push(result.patch);
1076
+ this.notify();
1077
+ return result.patch;
1078
+ }
1079
+ /**
1080
+ * Insert an image at the given world position. Wraps `addElement`
1081
+ * with the image-shape boilerplate (id, layer, order, scale=1,
1082
+ * rotation=0). Returns the new shape's id so callers can chain
1083
+ * (e.g. immediately reparent into a container).
1084
+ *
1085
+ * `src` is anything the renderer accepts — a data-URL from the
1086
+ * file-drop handler, a host CDN URL, an SVG string in
1087
+ * `image/svg+xml;base64,...` form.
1088
+ */
1089
+ // Pure body in `./editor/public/image-insert.ts`.
1090
+ insertImage(input) {
1091
+ const id = castElementId(this.uniqueId("img"));
1092
+ const shape = buildImageElement(this._scene, input, id, this._activeLayerId);
1093
+ this.addElement(shape);
1094
+ if (input.animated) {
1095
+ this.initPlayback(id);
1096
+ this.maybeAnimate();
1097
+ }
1098
+ return id;
1099
+ }
1100
+ async addBinaryFile(blob, name) {
1101
+ const result = await computeAddBinaryFile(this._scene, blob, name, () => ++this.nextId);
1102
+ this._scene = result.scene;
1103
+ this._history.push(result.patch);
1104
+ return result.id;
1105
+ }
1106
+ /**
1107
+ * Animation tick — runs while any shape carries
1108
+ * `metadata.animated` (GIFs today; future video / lottie).
1109
+ * Forces a full re-render every frame so drawImage picks up the
1110
+ * current frame of natively-animated elements. Self-terminates
1111
+ * when no animated shapes remain.
1112
+ *
1113
+ * Lifecycle managed by the `AnimationTick` helper (see
1114
+ * `./animation-tick.ts`). `insertImage({animated:true})` and
1115
+ * `loadScene` start the tick; `dispose()` stops it.
1116
+ */
1117
+ /** EMA of animation-tick render cost (ms) — drives the adaptive throttle. */
1118
+ gifRenderCostEma = 0;
1119
+ /** Wall-clock of the last animation-tick render — for the interval throttle. */
1120
+ lastGifTickMs = 0;
1121
+ animationTick = new AnimationTick({
1122
+ // Keep ticking only while an animated shape is actually on-screen.
1123
+ // Frame selection is wall-clock-based, so when the GIF scrolls back
1124
+ // into view the tick resumes on the correct frame. The tick is re-armed
1125
+ // on viewport changes via `maybeAnimate()` in `notify()`.
1126
+ isAnimated: () => this.hasVisibleAnimatedElement(),
1127
+ onTick: () => {
1128
+ // Adaptive throttle — skip this rAF if an animation frame was rendered
1129
+ // too recently. The target interval grows with the measured render
1130
+ // cost so a heavy scene drops GIF fps instead of blowing the frame
1131
+ // budget.
1132
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
1133
+ const target = Math.min(ANIMATION_MAX_INTERVAL_MS, Math.max(ANIMATION_MIN_INTERVAL_MS, this.gifRenderCostEma * ANIMATION_COST_FACTOR));
1134
+ if (now - this.lastGifTickMs < target)
1135
+ return;
1136
+ this.lastGifTickMs = now;
1137
+ // Freeze heavy GIFs that have played long enough.
1138
+ this.autoStopHeavyGifs();
1139
+ // Force a full re-render: the scene reference hasn't changed,
1140
+ // but the animation adapter advanced the GIF frame. Re-painting
1141
+ // picks up the current frame.
1142
+ this.lastRenderedScene = null;
1143
+ this.render();
1144
+ const cost = (typeof performance !== "undefined" ? performance.now() : Date.now()) - now;
1145
+ // EMA so a single spike doesn't overreact; decays back when load drops.
1146
+ this.gifRenderCostEma = this.gifRenderCostEma * 0.8 + cost * 0.2;
1147
+ },
1148
+ });
1149
+ // Pure body in `./editor/public/image-insert.ts`.
1150
+ hasAnimatedElement() {
1151
+ return hasAnimatedElement(this._scene);
1152
+ }
1153
+ /**
1154
+ * True when at least one animated shape's world AABB intersects the
1155
+ * current viewport. Drives viewport-culling of the animation tick —
1156
+ * off-screen GIFs don't burn decode / render cost, and the wall-clock
1157
+ * frame selection means they show the right frame the moment they
1158
+ * scroll back in.
1159
+ */
1160
+ hasVisibleAnimatedElement() {
1161
+ if (!hasAnimatedElement(this._scene))
1162
+ return false;
1163
+ const viewport = this.computeViewportWorld();
1164
+ if (!viewport)
1165
+ return true; // no viewport yet — don't suppress
1166
+ for (const shape of this._scene.elements.values()) {
1167
+ if (shape.metadata?.animated !== true)
1168
+ continue;
1169
+ if (B.intersects(getElementWorldBounds(shape), viewport))
1170
+ return true;
1171
+ }
1172
+ return false;
1173
+ }
1174
+ /**
1175
+ * Re-arm the animation tick after a change that may have brought an
1176
+ * animated shape into (or out of) view — pan / zoom / scene edit.
1177
+ * `AnimationTick.start()` no-ops when already running or when
1178
+ * `isAnimated()` is false, so this is cheap to call from `notify()`.
1179
+ */
1180
+ maybeAnimate() {
1181
+ if (this.hasVisibleAnimatedElement())
1182
+ this.animationTick.start();
1183
+ }
1184
+ /** Bound `visibilitychange` handler — pause/resume the tick. */
1185
+ onVisibilityChange = () => {
1186
+ if (typeof document === "undefined")
1187
+ return;
1188
+ if (document.hidden) {
1189
+ this.animationTick.stop();
1190
+ }
1191
+ else {
1192
+ this.maybeAnimate();
1193
+ }
1194
+ };
1195
+ // ── Per-shape GIF playback (auto-stop + reduced-motion) ──────
1196
+ /**
1197
+ * Transient per-shape playback state for animated images. `originMs`
1198
+ * is the wall-clock the current play run started; `frozenMs` is the
1199
+ * playback offset a paused shape is held at. Not serialised — purely
1200
+ * a runtime view, rebuilt on insert / rehydrate.
1201
+ */
1202
+ playbackState = new Map();
1203
+ /** Element id currently hovered — a hovered heavy GIF keeps playing
1204
+ * (its auto-stop timer is held off). Set by the pointer hover path. */
1205
+ hoveredAnimatedId = null;
1206
+ static nowMs() {
1207
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
1208
+ }
1209
+ static prefersReducedMotion() {
1210
+ if (typeof matchMedia !== "function")
1211
+ return false;
1212
+ try {
1213
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
1214
+ }
1215
+ catch {
1216
+ return false;
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Seed playback for a freshly-animated shape. Start paused (frozen on
1221
+ * frame 0) when the user prefers reduced motion; playing otherwise.
1222
+ */
1223
+ initPlayback(id) {
1224
+ if (this.playbackState.has(id))
1225
+ return;
1226
+ const now = Editor.nowMs();
1227
+ this.playbackState.set(id, {
1228
+ playing: !Editor.prefersReducedMotion(),
1229
+ originMs: now,
1230
+ playStartMs: now,
1231
+ frozenMs: 0,
1232
+ });
1233
+ }
1234
+ /** Playback timestamp fed to the renderer's animation clock for a
1235
+ * shape: wall-clock when unmanaged, play offset when playing, the
1236
+ * frozen frame when paused. */
1237
+ playbackClock(elementId) {
1238
+ const st = this.playbackState.get(elementId);
1239
+ const now = Editor.nowMs();
1240
+ if (!st)
1241
+ return now;
1242
+ return st.playing ? now - st.originMs : st.frozenMs;
1243
+ }
1244
+ /**
1245
+ * Toggle GIF playback for a shape — wired to a click on an animated
1246
+ * image (resume after auto-stop, play after reduced-motion). Resuming
1247
+ * continues from the frozen frame.
1248
+ */
1249
+ togglePlayback(id) {
1250
+ const now = Editor.nowMs();
1251
+ const st = this.playbackState.get(id);
1252
+ if (!st) {
1253
+ this.playbackState.set(id, { playing: true, originMs: now, playStartMs: now, frozenMs: 0 });
1254
+ }
1255
+ else if (st.playing) {
1256
+ st.frozenMs = now - st.originMs;
1257
+ st.playing = false;
1258
+ }
1259
+ else {
1260
+ // Resume from the frozen frame AND restart the auto-stop timer,
1261
+ // otherwise a heavy GIF (frozen past GIF_AUTOSTOP_MS) would
1262
+ // re-trip auto-stop on the very next tick — playing one frame
1263
+ // then freezing again.
1264
+ st.originMs = now - st.frozenMs;
1265
+ st.playStartMs = now;
1266
+ st.playing = true;
1267
+ }
1268
+ this.maybeAnimate();
1269
+ this.scheduleRender();
1270
+ }
1271
+ /**
1272
+ * Hover entered an animated shape: resume it if paused and hold off
1273
+ * its auto-stop timer while the pointer stays over it (the auto-stop
1274
+ * pass keeps pushing `playStartMs` forward for the hovered shape).
1275
+ * Pass `null` when the pointer leaves all shapes.
1276
+ */
1277
+ hoverAnimatedElement(id) {
1278
+ if (this.hoveredAnimatedId === id)
1279
+ return;
1280
+ this.hoveredAnimatedId = id;
1281
+ if (id !== null) {
1282
+ const st = this.playbackState.get(id);
1283
+ const now = Editor.nowMs();
1284
+ if (st && !st.playing) {
1285
+ st.originMs = now - st.frozenMs;
1286
+ st.playStartMs = now;
1287
+ st.playing = true;
1288
+ this.maybeAnimate();
1289
+ this.scheduleRender();
1290
+ }
1291
+ }
1292
+ }
1293
+ /** True when the shape's GIF is paused (drives the overlay badge). */
1294
+ isPlaybackPaused(id) {
1295
+ return this.playbackState.get(id)?.playing === false;
1296
+ }
1297
+ /**
1298
+ * Record the idle cursor position so the overlay can grow the SINGLE
1299
+ * selected element's link-start dot nearest the cursor. Only the selected
1300
+ * element shows start dots (connecting from an unselected element on hover
1301
+ * was a cancelled product decision). Pass `null` to clear.
1302
+ */
1303
+ setHoverCursorWorld(cursor) {
1304
+ this.hoverCursorWorld = cursor;
1305
+ // Dots only render for a single selection; skip notify otherwise.
1306
+ if (this._selection.size === 1)
1307
+ this.notify();
1308
+ }
1309
+ /** Live link-draw preview polyline (elbow), or null when not drawing. */
1310
+ get linkPreviewPath() {
1311
+ return this.edgePreview?.points ?? null;
1312
+ }
1313
+ /** Current connector attach target + mode (point=fixed / element=floating). */
1314
+ get linkAttachTarget() {
1315
+ const t = this.hoveredLinkTarget;
1316
+ return t ? { elementId: t.elementId, mode: t.mode } : null;
1317
+ }
1318
+ /**
1319
+ * Freeze heavy GIFs after `GIF_AUTOSTOP_MS` of continuous play.
1320
+ * Light GIFs (small byte payload) loop forever. Called from the tick
1321
+ * before each animation render.
1322
+ */
1323
+ autoStopHeavyGifs() {
1324
+ const now = Editor.nowMs();
1325
+ for (const shape of this._scene.elements.values()) {
1326
+ if (shape.type !== "image")
1327
+ continue;
1328
+ const img = shape;
1329
+ if (!img.animationKind)
1330
+ continue;
1331
+ const st = this.playbackState.get(img.id);
1332
+ if (!st?.playing)
1333
+ continue;
1334
+ const heavy = img.animationData instanceof ArrayBuffer && img.animationData.byteLength > HEAVY_GIF_BYTES;
1335
+ if (!heavy)
1336
+ continue;
1337
+ // Hovered heavy GIF keeps playing — push its timer forward so it
1338
+ // never auto-stops while the pointer is over it.
1339
+ if (img.id === this.hoveredAnimatedId) {
1340
+ st.playStartMs = now;
1341
+ continue;
1342
+ }
1343
+ if (now - st.playStartMs > GIF_AUTOSTOP_MS) {
1344
+ st.frozenMs = now - st.originMs;
1345
+ st.playing = false;
1346
+ }
1347
+ }
1348
+ }
1349
+ /**
1350
+ * Restore transient `animationData` for animated image shapes after
1351
+ * a scene load. The raw GIF bytes don't survive serialisation
1352
+ * (`serializeScene` strips the ArrayBuffer), but they're persisted
1353
+ * in `Scene.files` via the shape's `fileId`. Here we copy the bytes
1354
+ * back onto `shape.animationData` so the registered animation
1355
+ * adapter (host-side, e.g. the gifuct decoder) can produce frames.
1356
+ *
1357
+ * Applied directly to `_scene` (no history entry — this is an
1358
+ * internal rehydration, not a user edit). No-op for shapes that
1359
+ * already carry live `animationData` or lack a resolvable file.
1360
+ */
1361
+ rehydrateAnimatedImages() {
1362
+ for (const shape of this._scene.elements.values()) {
1363
+ if (shape.type !== "image")
1364
+ continue;
1365
+ const img = shape;
1366
+ if (!img.animationKind)
1367
+ continue;
1368
+ // Seed playback for every animated shape loaded from the scene
1369
+ // (reduced-motion is honoured at this point too).
1370
+ this.initPlayback(img.id);
1371
+ if (!img.fileId)
1372
+ continue;
1373
+ if (img.animationData instanceof ArrayBuffer)
1374
+ continue; // already live
1375
+ const file = getBinaryFile(this._scene, img.fileId);
1376
+ if (!file)
1377
+ continue;
1378
+ this._scene = apply(this._scene, {
1379
+ kind: "element",
1380
+ id: img.id,
1381
+ before: img,
1382
+ after: { ...img, animationData: file.data },
1383
+ });
1384
+ }
1385
+ }
1386
+ /**
1387
+ * Drag-to-place flow for palette templates. Adds the shape to the
1388
+ * scene immediately so the user sees it dragging under the cursor,
1389
+ * but defers the history entry until `commit()` is called. `update`
1390
+ * re-positions without writing per-move patches; `cancel` removes
1391
+ * the shape entirely and leaves history untouched (no undo entry).
1392
+ *
1393
+ * Typical wiring: HTML5 dragenter starts the placement, dragover
1394
+ * updates, drop commits, dragleave / window keydown(Escape) cancel.
1395
+ */
1396
+ // Placement helpers live in `./editor/public/placement.ts`.
1397
+ // Editor owns the transaction lifecycle and selection mutate;
1398
+ // the closure threads scene mutations through the pure helpers.
1399
+ beginPlacement(shape) {
1400
+ const tx = this._history.transaction();
1401
+ const { scene: add, state: initialState } = beginPlacementState(shape);
1402
+ const initial = add(this._scene);
1403
+ this._scene = initial.scene;
1404
+ this._selection = Selection.single(shape.id);
1405
+ this.notify();
1406
+ const state = { ...initialState };
1407
+ return {
1408
+ update: (worldCenter) => {
1409
+ const r = computePlacementUpdate(this._scene, state, worldCenter);
1410
+ this._scene = r.scene;
1411
+ state.current = r.next;
1412
+ this.notify();
1413
+ },
1414
+ commit: () => {
1415
+ const drop = computePlacementContainerDrop(this._scene, state);
1416
+ if (drop) {
1417
+ this._scene = drop.scene;
1418
+ state.current = drop.next;
1419
+ }
1420
+ tx.add({ kind: "element", id: shape.id, before: null, after: state.current });
1421
+ tx.commit();
1422
+ // Notify is mandatory here. The dragover snapshots carried the
1423
+ // placement preview WITHOUT `parentId`, so the
1424
+ // AutoLayoutScheduler's `signatureFor(parent)` did not include the
1425
+ // new child and no `runAutoLayout` was scheduled. The reparent above
1426
+ // set `parentId`; this final `notify()` lets the scheduler see the
1427
+ // change so the child is laid out immediately instead of on the next
1428
+ // unrelated notification.
1429
+ this.notify();
1430
+ },
1431
+ cancel: () => {
1432
+ const { scene } = computePlacementCancel(this._scene, shape.id);
1433
+ this._scene = scene;
1434
+ tx.cancel();
1435
+ this._selection = Selection.EMPTY;
1436
+ this.notify();
1437
+ },
1438
+ };
1439
+ }
1440
+ // Pure body in `./editor/public/selection-ops.ts`.
1441
+ deleteSelected() {
1442
+ const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
1443
+ if (!result)
1444
+ return;
1445
+ const tx = this._history.transaction();
1446
+ this._scene = result.scene;
1447
+ for (const patch of result.patches)
1448
+ tx.add(patch);
1449
+ tx.commit();
1450
+ this._selection = Selection.EMPTY;
1451
+ this._selectedLinks = LinkSelection.EMPTY;
1452
+ this.notify();
1453
+ }
1454
+ // --- Inline text editing ---
1455
+ /**
1456
+ * Currently edited text shape (or null). Set by `beginTextEdit`;
1457
+ * cleared by `commitTextEdit` / `cancelTextEdit`. The host overlay
1458
+ * (`<TextEditorOverlay>` in `@react-ui`) subscribes via `editor`
1459
+ * and renders a `<textarea>` positioned over the shape.
1460
+ */
1461
+ _editingTextElement = null;
1462
+ get editingTextElement() {
1463
+ return this._editingTextElement;
1464
+ }
1465
+ /** Link whose caption is being edited inline (double-click), or null. */
1466
+ _editingLinkCaption = null;
1467
+ get editingLinkCaption() {
1468
+ return this._editingLinkCaption;
1469
+ }
1470
+ /**
1471
+ * Frame whose NAME (header label) is being edited inline (double-click
1472
+ * the header), or null. The host overlay (`<FrameNameEditorOverlay>` in
1473
+ * `@react-ui`) renders an input over the header and commits the name.
1474
+ */
1475
+ _editingFrameName = null;
1476
+ get editingFrameName() {
1477
+ return this._editingFrameName;
1478
+ }
1479
+ /**
1480
+ * When the `draw-text` tool just placed a shape and opened its
1481
+ * editor, this holds that shape's id until the first commit. A
1482
+ * pending creation isn't in history yet: committing non-empty text
1483
+ * records a single add patch (whole shape = one undo); committing
1484
+ * empty / cancelling removes it with no history entry at all.
1485
+ */
1486
+ _pendingTextCreate = null;
1487
+ /**
1488
+ * Snapshot of the shape at edit start. Used to revert on cancel and
1489
+ * as the `before` of the single commit patch. `null` for a pending
1490
+ * creation (the shape didn't exist yet).
1491
+ */
1492
+ _textEditOrigin = null;
1493
+ /**
1494
+ * Live selection inside the edited text, mirrored from the hidden
1495
+ * `<textarea>` (`start`/`end` are source offsets, `dir` is the
1496
+ * anchored end). The caret is `dir === "backward" ? start : end`.
1497
+ */
1498
+ _textSel = null;
1499
+ /** Anchor offset for a canvas drag-select inside the edited text. */
1500
+ _textDragAnchor = null;
1501
+ _caretBlinkOn = true;
1502
+ _caretBlinkTimer = null;
1503
+ get editingTextSelection() {
1504
+ return this._textSel;
1505
+ }
1506
+ /** Caret offset = the moving end of the selection. */
1507
+ get editingTextCaret() {
1508
+ if (!this._textSel)
1509
+ return null;
1510
+ return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
1511
+ }
1512
+ get caretBlinkOn() {
1513
+ return this._caretBlinkOn;
1514
+ }
1515
+ /** `true` while a canvas drag-select inside the edited text is active. */
1516
+ get isTextDragging() {
1517
+ return this._textDragAnchor !== null;
1518
+ }
1519
+ startCaretBlink() {
1520
+ this._caretBlinkOn = true;
1521
+ this.stopCaretBlink();
1522
+ // Only run the blink when a DOM clock exists (browser host). Node
1523
+ // test envs construct the editor without a window — skip so a
1524
+ // dangling interval can't keep the process alive.
1525
+ if (typeof window === "undefined")
1526
+ return;
1527
+ this._caretBlinkTimer = setInterval(() => {
1528
+ this._caretBlinkOn = !this._caretBlinkOn;
1529
+ this.notify();
1530
+ }, CARET_BLINK_INTERVAL_MS);
1531
+ }
1532
+ stopCaretBlink() {
1533
+ if (this._caretBlinkTimer !== null) {
1534
+ clearInterval(this._caretBlinkTimer);
1535
+ this._caretBlinkTimer = null;
1536
+ }
1537
+ }
1538
+ /** Reset the caret to solid (called on type / move so it never blinks off mid-action). */
1539
+ wakeCaret() {
1540
+ this._caretBlinkOn = true;
1541
+ }
1542
+ /**
1543
+ * Begin editing a text shape's body. No-op when the shape doesn't
1544
+ * exist or isn't a text shape. Concurrent edits commit themselves
1545
+ * (only one shape at a time). Caret defaults to the end of the text.
1546
+ */
1547
+ // Pure bodies in `./editor/public/text-edit.ts`.
1548
+ /** Open inline caption editing for a link (double-click). */
1549
+ beginLinkCaptionEdit(id) {
1550
+ if (!getLink(this._scene, id))
1551
+ return;
1552
+ if (this._editingTextElement !== null)
1553
+ this.commitTextEdit();
1554
+ this._editingLinkCaption = id;
1555
+ this.notify();
1556
+ }
1557
+ /**
1558
+ * Commit the link caption. Empty / whitespace text removes the label;
1559
+ * otherwise the label text is set, preserving any existing position /
1560
+ * styling. One undo step. Clears caption-edit mode.
1561
+ */
1562
+ commitLinkCaptionEdit(text) {
1563
+ const id = this._editingLinkCaption;
1564
+ this._editingLinkCaption = null;
1565
+ if (id === null) {
1566
+ this.notify();
1567
+ return;
1568
+ }
1569
+ const edge = getLink(this._scene, id);
1570
+ if (edge) {
1571
+ const trimmed = text.trim();
1572
+ const nextLabel = trimmed === "" ? undefined : { ...(edge.label ?? {}), text: trimmed };
1573
+ const same = (edge.label?.text ?? "") === (nextLabel?.text ?? "");
1574
+ if (!same) {
1575
+ const r = updateLink(this._scene, id, (e) => {
1576
+ const next = { ...e };
1577
+ if (nextLabel)
1578
+ next.label = nextLabel;
1579
+ else
1580
+ delete next.label;
1581
+ return next;
1582
+ });
1583
+ this._scene = r.scene;
1584
+ this._history.push(r.patch);
1585
+ }
1586
+ }
1587
+ this.notify();
1588
+ }
1589
+ /** Cancel link caption editing without changing the label. */
1590
+ cancelLinkCaptionEdit() {
1591
+ if (this._editingLinkCaption === null)
1592
+ return;
1593
+ this._editingLinkCaption = null;
1594
+ this.notify();
1595
+ }
1596
+ /** World-space anchor point for a link's caption (midpoint of its path). */
1597
+ linkLabelWorld(id) {
1598
+ const edge = getLink(this._scene, id);
1599
+ if (!edge)
1600
+ return null;
1601
+ const path = getLinkPath(this._scene, edge);
1602
+ if (!path || path.length < 2)
1603
+ return null;
1604
+ const t = edge.label?.position ?? 0.5;
1605
+ let total = 0;
1606
+ for (let i = 1; i < path.length; i++)
1607
+ total += distanceTo(req(path[i - 1]), req(path[i]));
1608
+ let remaining = total * t;
1609
+ for (let i = 1; i < path.length; i++) {
1610
+ const a = req(path[i - 1]);
1611
+ const b = req(path[i]);
1612
+ const seg = distanceTo(a, b);
1613
+ if (remaining <= seg) {
1614
+ const r = seg === 0 ? 0 : remaining / seg;
1615
+ return { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r };
1616
+ }
1617
+ remaining -= seg;
1618
+ }
1619
+ return req(path[path.length - 1]);
1620
+ }
1621
+ beginTextEdit(id) {
1622
+ if (!canBeginTextEdit(this._scene, id, (lid) => this.isLayerLocked(lid)))
1623
+ return;
1624
+ // Commit any in-flight edit on a different shape first.
1625
+ if (this._editingTextElement !== null && this._editingTextElement !== id)
1626
+ this.commitTextEdit();
1627
+ this._editingTextElement = id;
1628
+ this._textEditOrigin =
1629
+ this._pendingTextCreate === id ? null : (getElement(this._scene, id) ?? null);
1630
+ const shape = getElement(this._scene, id);
1631
+ const len = shape?.text.length ?? 0;
1632
+ this._textSel = { start: len, end: len, dir: "forward" };
1633
+ this.startCaretBlink();
1634
+ this.notify();
1635
+ }
1636
+ // --- Frame name inline editing (double-click the header) ---
1637
+ /**
1638
+ * Start editing a frame's header name. No-op unless `id` is a frame on
1639
+ * an unlocked layer. Commits any in-flight text edit first.
1640
+ */
1641
+ beginFrameNameEdit(id) {
1642
+ const shape = getElement(this._scene, id);
1643
+ if (shape?.type !== "frame")
1644
+ return;
1645
+ if (this.isLayerLocked(shape.layerId))
1646
+ return;
1647
+ if (this._editingTextElement !== null)
1648
+ this.commitTextEdit();
1649
+ this._editingFrameName = id;
1650
+ this.notify();
1651
+ }
1652
+ /**
1653
+ * Commit the edited frame name. Empty / whitespace-only clears the
1654
+ * stored name (the renderer falls back to "Frame"). One history step;
1655
+ * no-op when the name is unchanged. Always clears the editing state.
1656
+ */
1657
+ commitFrameNameEdit(name) {
1658
+ const id = this._editingFrameName;
1659
+ if (id === null)
1660
+ return;
1661
+ this._editingFrameName = null;
1662
+ const shape = getElement(this._scene, id);
1663
+ if (shape?.type === "frame") {
1664
+ const trimmed = name.trim();
1665
+ const current = shape.name ?? "";
1666
+ if (trimmed !== current) {
1667
+ const r = updateElement(this._scene, id, (s) => {
1668
+ const copy = { ...s };
1669
+ // `exactOptionalPropertyTypes`: drop the key when cleared.
1670
+ if (trimmed === "")
1671
+ delete copy.name;
1672
+ else
1673
+ copy.name = trimmed;
1674
+ return copy;
1675
+ });
1676
+ this._scene = r.scene;
1677
+ this._history.push(r.patch);
1678
+ }
1679
+ }
1680
+ this.notify();
1681
+ }
1682
+ /** Abandon the frame-name edit without changing the name. */
1683
+ cancelFrameNameEdit() {
1684
+ if (this._editingFrameName === null)
1685
+ return;
1686
+ this._editingFrameName = null;
1687
+ this.notify();
1688
+ }
1689
+ /**
1690
+ * Frame whose header strip (the label bar ABOVE the body) contains the
1691
+ * world point — top-most by z-order. Used to route a double-click on the
1692
+ * header to a name edit, since the header sits outside the frame's
1693
+ * hit-test bounds. Assumes unrotated frames (the common case).
1694
+ */
1695
+ frameHeaderAt(p) {
1696
+ let bestId = null;
1697
+ let bestOrder = "";
1698
+ for (const s of this._scene.elements.values()) {
1699
+ if (!isFrame(s))
1700
+ continue;
1701
+ const hx = s.position.x;
1702
+ // The header strip can extend up to the frame's full width (it hugs
1703
+ // the label but is capped there), so the rename hit zone spans it.
1704
+ const hw = s.width * s.scale.x;
1705
+ const hh = FRAME_HEADER_HEIGHT * s.scale.y;
1706
+ const hyTop = s.position.y - hh;
1707
+ if (p.x >= hx && p.x <= hx + hw && p.y >= hyTop && p.y <= hyTop + hh) {
1708
+ if (bestId === null || s.order > bestOrder) {
1709
+ bestId = s.id;
1710
+ bestOrder = s.order;
1711
+ }
1712
+ }
1713
+ }
1714
+ return bestId;
1715
+ }
1716
+ /**
1717
+ * Live edit transport from the hidden `<textarea>`: replace the
1718
+ * shape's text + selection as the user types / pastes / composes.
1719
+ * Mutates the scene WITHOUT a history entry — history is recorded
1720
+ * once on commit. No-op when not editing.
1721
+ */
1722
+ setEditingText(value, selStart, selEnd, dir = "forward") {
1723
+ const id = this._editingTextElement;
1724
+ if (!id)
1725
+ return;
1726
+ const r = updateElement(this._scene, id, (s) => ({ ...s, text: value }));
1727
+ this._scene = r.scene;
1728
+ this._textSel = { start: selStart, end: selEnd, dir };
1729
+ this.wakeCaret();
1730
+ this.notify();
1731
+ }
1732
+ /** Selection-only update (arrows / shift-select / click) — no text change. */
1733
+ setEditingSelection(selStart, selEnd, dir = "forward") {
1734
+ if (!this._editingTextElement)
1735
+ return;
1736
+ this._textSel = { start: selStart, end: selEnd, dir };
1737
+ this.wakeCaret();
1738
+ this.notify();
1739
+ }
1740
+ /**
1741
+ * Map a world-space point to a caret offset in the edited text. Used
1742
+ * to place / extend the caret from canvas clicks. Returns `null` when
1743
+ * not editing or the shape is gone.
1744
+ */
1745
+ caretIndexAtWorldPoint(worldPoint) {
1746
+ const id = this._editingTextElement;
1747
+ if (!id)
1748
+ return null;
1749
+ const shape = getElement(this._scene, id);
1750
+ if (shape?.type !== "text")
1751
+ return null;
1752
+ const layout = this.editingTextLayout(shape);
1753
+ if (!layout)
1754
+ return null;
1755
+ // World → shape-local (translate by position; rotation/scale on text
1756
+ // edit is uncommon — ignore for hit purposes).
1757
+ const local = { x: worldPoint.x - shape.position.x, y: worldPoint.y - shape.position.y };
1758
+ const align = shape.style.textAlign ?? "left";
1759
+ return pointToCaretIndex(layout, local, this.measureFor(shape), align);
1760
+ }
1761
+ /**
1762
+ * `true` when a point is inside the currently-edited text shape's
1763
+ * world bounds. Used by the pointer binding to decide between
1764
+ * repositioning the caret (inside) and committing (outside).
1765
+ */
1766
+ editedElementContainsPoint(worldPoint) {
1767
+ const id = this._editingTextElement;
1768
+ if (!id)
1769
+ return false;
1770
+ const shape = getElement(this._scene, id);
1771
+ if (!shape)
1772
+ return false;
1773
+ const b = getElementWorldBounds(shape);
1774
+ return (worldPoint.x >= b.x &&
1775
+ worldPoint.x <= b.x + b.width &&
1776
+ worldPoint.y >= b.y &&
1777
+ worldPoint.y <= b.y + b.height);
1778
+ }
1779
+ /** Place a collapsed caret at the clicked point and start a drag-select. */
1780
+ setTextCaretFromPoint(worldPoint) {
1781
+ const idx = this.caretIndexAtWorldPoint(worldPoint);
1782
+ if (idx === null)
1783
+ return;
1784
+ this._textDragAnchor = idx;
1785
+ this.setEditingSelection(idx, idx, "forward");
1786
+ }
1787
+ /** Extend the selection from the drag anchor to the current point. */
1788
+ extendTextSelectionToPoint(worldPoint) {
1789
+ if (this._textDragAnchor === null)
1790
+ return;
1791
+ const idx = this.caretIndexAtWorldPoint(worldPoint);
1792
+ if (idx === null)
1793
+ return;
1794
+ const anchor = this._textDragAnchor;
1795
+ if (idx >= anchor)
1796
+ this.setEditingSelection(anchor, idx, "forward");
1797
+ else
1798
+ this.setEditingSelection(idx, anchor, "backward");
1799
+ }
1800
+ /** End a canvas drag-select (clears the drag anchor). */
1801
+ endTextDragSelect() {
1802
+ this._textDragAnchor = null;
1803
+ }
1804
+ /** Build the editable layout for a text shape using the main target's metrics. */
1805
+ editingTextLayout(shape) {
1806
+ return layoutText(shape.text, this.measureFor(shape), {
1807
+ fontSize: shape.fontSize,
1808
+ ...(shape.maxWidth !== undefined ? { maxWidth: shape.maxWidth } : {}),
1809
+ });
1810
+ }
1811
+ /**
1812
+ * A measure callback bound to a shape's font, using the main target's
1813
+ * `measureText` — the SAME source the renderer draws with (WebGL2
1814
+ * reports MSDF advances) and the bounder measures with. Caret /
1815
+ * selection geometry therefore lines up exactly with the glyphs.
1816
+ */
1817
+ measureFor(shape) {
1818
+ const target = this.mainTarget;
1819
+ // Match the rendered weight/style so caret / selection geometry lines
1820
+ // up with bold / italic glyphs (which have different advances).
1821
+ target.setFont(shape.fontFamily, shape.fontSize, {
1822
+ ...(shape.style.fontWeight === "bold" ? { weight: "bold" } : {}),
1823
+ ...(shape.style.fontStyle === "italic" ? { style: "italic" } : {}),
1824
+ });
1825
+ return (s) => target.measureText(s).width;
1826
+ }
1827
+ /**
1828
+ * World-space caret + selection geometry for the overlay pass.
1829
+ * Returns `null` when not editing. The caret is `null` while blinked
1830
+ * off so the overlay can simply skip drawing it.
1831
+ */
1832
+ editingTextOverlay() {
1833
+ const id = this._editingTextElement;
1834
+ if (!id || !this._textSel)
1835
+ return null;
1836
+ const shape = getElement(this._scene, id);
1837
+ if (shape?.type !== "text")
1838
+ return null;
1839
+ const layout = this.editingTextLayout(shape);
1840
+ if (!layout)
1841
+ return null;
1842
+ const align = shape.style.textAlign ?? "left";
1843
+ const measure = this.measureFor(shape);
1844
+ const { x: px, y: py } = shape.position;
1845
+ const local = textSelectionRects(layout, this._textSel.start, this._textSel.end, measure, align);
1846
+ const selectionRects = local.map((r) => ({
1847
+ x: px + r.x,
1848
+ y: py + r.y,
1849
+ width: r.width,
1850
+ height: r.height,
1851
+ }));
1852
+ let caret = null;
1853
+ if (this._caretBlinkOn) {
1854
+ const cIdx = this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
1855
+ const g = caretGeometry(layout, cIdx, measure, shape.fontSize, align);
1856
+ caret = { x: px + g.x, y: py + g.y, height: g.height };
1857
+ }
1858
+ return { caret, caretColor: shape.style.fill ?? "#1a1a1a", selectionRects };
1859
+ }
1860
+ commitTextEdit(next) {
1861
+ const id = this._editingTextElement;
1862
+ if (!id)
1863
+ return;
1864
+ const pending = this._pendingTextCreate === id;
1865
+ const origin = this._textEditOrigin;
1866
+ // Optional explicit text (keyboard / test callers); the live path
1867
+ // passes nothing because the scene already holds the typed text.
1868
+ if (next !== undefined) {
1869
+ this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: next })).scene;
1870
+ }
1871
+ this._editingTextElement = null;
1872
+ this._pendingTextCreate = null;
1873
+ this._textEditOrigin = null;
1874
+ this._textSel = null;
1875
+ this.stopCaretBlink();
1876
+ const finalElement = getElement(this._scene, id);
1877
+ const text = finalElement?.text ?? "";
1878
+ // Empty (whitespace-only) text removes the shape. Pending = silent
1879
+ // (never recorded); existing = recorded so undo restores the origin.
1880
+ if (text.trim() === "") {
1881
+ if (finalElement) {
1882
+ this._scene = removeElement(this._scene, id).scene;
1883
+ if (!pending && origin) {
1884
+ this._history.push({ kind: "element", id, before: origin, after: null });
1885
+ }
1886
+ if (this._selection.has(id))
1887
+ this._selection = Selection.EMPTY;
1888
+ }
1889
+ this.notify();
1890
+ return;
1891
+ }
1892
+ if (pending) {
1893
+ // Record the whole creation as one add patch.
1894
+ if (finalElement)
1895
+ this._history.push({ kind: "element", id, before: null, after: finalElement });
1896
+ }
1897
+ else if (origin && finalElement) {
1898
+ // Existing edit: record ONLY the text delta. Other fields (font
1899
+ // size etc.) changed via the panel push their own history during
1900
+ // the edit, so the commit's `before` keeps the final non-text
1901
+ // state and rewinds just the text.
1902
+ const originText = origin.text;
1903
+ if (originText !== finalElement.text) {
1904
+ const before = { ...finalElement, text: originText };
1905
+ this._history.push({ kind: "element", id, before, after: finalElement });
1906
+ }
1907
+ }
1908
+ this.notify();
1909
+ }
1910
+ cancelTextEdit() {
1911
+ const id = this._editingTextElement;
1912
+ if (id === null)
1913
+ return;
1914
+ const pending = this._pendingTextCreate === id;
1915
+ const origin = this._textEditOrigin;
1916
+ this._editingTextElement = null;
1917
+ this._pendingTextCreate = null;
1918
+ this._textEditOrigin = null;
1919
+ this._textSel = null;
1920
+ this.stopCaretBlink();
1921
+ // Revert live edits with no history entry. Pending creations are
1922
+ // removed entirely; existing shapes have only their TEXT restored
1923
+ // (panel-driven field changes during the edit keep their own
1924
+ // committed history and must survive the cancel).
1925
+ if (pending) {
1926
+ if (getElement(this._scene, id)) {
1927
+ this._scene = removeElement(this._scene, id).scene;
1928
+ if (this._selection.has(id))
1929
+ this._selection = Selection.EMPTY;
1930
+ }
1931
+ }
1932
+ else if (origin) {
1933
+ const originText = origin.text;
1934
+ this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: originText })).scene;
1935
+ }
1936
+ this.notify();
1937
+ }
1938
+ /**
1939
+ * Translate every selected shape by the given world-space delta.
1940
+ * Single undo step. No-op when selection is empty. Used by arrow-key
1941
+ * keyboard navigation; hosts pass `{ x: 1, y: 0 }` for fine nudge
1942
+ * and `{ x: 10, y: 0 }` for shift-arrow.
1943
+ */
1944
+ // Pure body in `./editor/public/selection-ops.ts`.
1945
+ moveSelectionBy(delta) {
1946
+ if (this._selection.size === 0 && this._selectedLinks.size === 0)
1947
+ return;
1948
+ // Locked / layer-locked elements don't move (they're still selectable).
1949
+ const targets = new Set([...this.expandSelectionWithDescendants()].filter((id) => {
1950
+ const s = getElement(this._scene, id);
1951
+ return s ? this.isElementManipulable(s) : false;
1952
+ }));
1953
+ const result = this._selection.size > 0
1954
+ ? computeMoveSelectionBy(this._scene, targets, delta, (lid) => this.isLayerLocked(lid))
1955
+ : null;
1956
+ // Selected links (translated whole, incl. free endpoints) + connectors
1957
+ // bound on both ends to nudged elements move by the same delta.
1958
+ const sceneAfterElements = result ? result.scene : this._scene;
1959
+ const linkResult = computeMovingLinkForNudge(sceneAfterElements, targets, this._selectedLinks, delta);
1960
+ if (!result && linkResult.patches.length === 0)
1961
+ return;
1962
+ const tx = this._history.transaction();
1963
+ this._scene = linkResult.scene;
1964
+ if (result)
1965
+ for (const patch of result.patches)
1966
+ tx.add(patch);
1967
+ for (const patch of linkResult.patches)
1968
+ tx.add(patch);
1969
+ tx.commit();
1970
+ this.notify();
1971
+ this.announce(describeNudgePure(delta, result?.moved ?? 0));
1972
+ }
1973
+ /**
1974
+ * Keyboard-friendly creation flow. Picks the shape type from the
1975
+ * current `mode` ("draw-rect" / "draw-ellipse" / fallback to
1976
+ * rectangle) and inserts a sensible default-sized shape at the
1977
+ * viewport center. Returns the new shape's id, or `null` when the
1978
+ * scene has no active layer.
1979
+ *
1980
+ * Hosts can bind this to "Enter" while in a draw mode, providing a
1981
+ * mouse-free alternative to drag-out creation.
1982
+ */
1983
+ // Pure body in `./editor/public/placement.ts`.
1984
+ createElementAtCursor() {
1985
+ const vp = this._scene.viewport;
1986
+ const world = this.screenToWorld({
1987
+ x: (vp.size.width || 200) / 2,
1988
+ y: (vp.size.height || 200) / 2,
1989
+ });
1990
+ const id = newElementIdAtCursor(++this.nextId);
1991
+ const shape = buildElementAtCursor(this._scene, this.mode, world, this._activeLayerId, id);
1992
+ const r = addElement(this._scene, shape);
1993
+ this._scene = r.scene;
1994
+ this._history.push(r.patch);
1995
+ this._selection = Selection.single(id);
1996
+ this.notify();
1997
+ this.announce(`Created ${shape.type} ${id}`);
1998
+ return id;
1999
+ }
2000
+ /**
2001
+ * `draw-text` tool: drop an empty text shape at `worldPoint`, select
2002
+ * it and open its inline editor immediately. The add is a single undo
2003
+ * step; the subsequent text typed in is committed (or the empty shape
2004
+ * removed) by `commitTextEdit`. Reverts to `select` afterwards unless
2005
+ * the tool is locked.
2006
+ */
2007
+ createTextAt(worldPoint) {
2008
+ const id = newElementIdAtCursor(++this.nextId);
2009
+ const shape = buildTextElementAt(this._scene, worldPoint, this._activeLayerId, id);
2010
+ // No history push here — the placeholder is "pending" until the
2011
+ // first commit (see `_pendingTextCreate`). This way an abandoned
2012
+ // text never pollutes the undo stack.
2013
+ const r = addElement(this._scene, shape);
2014
+ this._scene = r.scene;
2015
+ this._pendingTextCreate = id;
2016
+ this._selection = Selection.single(id);
2017
+ this.maybeRevertModeAfterCreate();
2018
+ this.notify();
2019
+ this.announce(`Created text ${id}`);
2020
+ this.beginTextEdit(id);
2021
+ return id;
2022
+ }
2023
+ // Pure bodies in `./editor/public/brush.ts`.
2024
+ beginBrushStroke(world, pressure = 0.5) {
2025
+ this.brushStroke = beginBrushStrokePure(world, pressure);
2026
+ this.notify();
2027
+ }
2028
+ extendBrushStroke(world, pressure = 0.5) {
2029
+ if (!this.brushStroke)
2030
+ return;
2031
+ extendBrushStrokePure(this.brushStroke, world, pressure);
2032
+ this.notify();
2033
+ }
2034
+ commitBrushStroke() {
2035
+ const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId));
2036
+ if (!result) {
2037
+ this.brushStroke = null;
2038
+ this.notify();
2039
+ return null;
2040
+ }
2041
+ this._scene = result.scene;
2042
+ this._history.push(result.patch);
2043
+ this.brushStroke = null;
2044
+ this.notify();
2045
+ return result.elementId;
2046
+ }
2047
+ cancelBrushStroke() {
2048
+ if (!this.brushStroke)
2049
+ return;
2050
+ this.brushStroke = null;
2051
+ this.notify();
2052
+ }
2053
+ /** Current in-progress brush stroke, exposed for the overlay preview. */
2054
+ get pendingBrushStroke() {
2055
+ return this.brushStroke;
2056
+ }
2057
+ // Pure bodies in `./editor/public/arrange-group.ts`.
2058
+ arrangeAsGrid(opts = {}) {
2059
+ const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
2060
+ const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
2061
+ if (!result)
2062
+ return;
2063
+ this._scene = result.scene;
2064
+ this._history.push(result.patch);
2065
+ this.notify();
2066
+ this.announce(`Arranged ${result.count} shapes on a ${result.cols}-column grid`);
2067
+ }
2068
+ arrangeAsStack(opts = {}) {
2069
+ const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
2070
+ const result = computeArrangeAsStack(this._scene, this._selection, opts, origin);
2071
+ if (!result)
2072
+ return;
2073
+ this._scene = result.scene;
2074
+ this._history.push(result.patch);
2075
+ this.notify();
2076
+ this.announce(`Stacked ${result.count} shapes ${result.direction}`);
2077
+ }
2078
+ groupSelected() {
2079
+ const result = computeGroupSelected(this._scene, this._selection, newGroupElementId(++this.nextId));
2080
+ if (!result)
2081
+ return { kind: "noop" };
2082
+ const tx = this._history.transaction();
2083
+ this._scene = result.scene;
2084
+ for (const patch of result.patches)
2085
+ tx.add(patch);
2086
+ tx.commit();
2087
+ this._selection = Selection.single(result.groupId);
2088
+ this.notify();
2089
+ return { kind: "grouped", groupId: result.groupId };
2090
+ }
2091
+ ungroup() {
2092
+ const result = computeUngroup(this._scene, this._selection);
2093
+ if (!result)
2094
+ return;
2095
+ const tx = this._history.transaction();
2096
+ this._scene = result.scene;
2097
+ for (const patch of result.patches)
2098
+ tx.add(patch);
2099
+ tx.commit();
2100
+ this._selection = new Set(result.nextSelection);
2101
+ this.notify();
2102
+ }
2103
+ selectionRoots() {
2104
+ return selectionRoots(this._scene, this._selection);
2105
+ }
2106
+ expandSelectionWithDescendants() {
2107
+ return expandSelectionWithDescendants(this._scene, this._selection);
2108
+ }
2109
+ focusCycle(direction) {
2110
+ const current = [...this._selection][0];
2111
+ const result = pickFocusCycle(this._scene, current, direction);
2112
+ if (!result)
2113
+ return;
2114
+ this._selection = Selection.single(result.id);
2115
+ this.notify();
2116
+ this.announce(`Selected ${result.name}`);
2117
+ }
2118
+ /**
2119
+ * Clear selection + cancel any in-progress drag / draw gesture.
2120
+ * Bound to Escape in default keyboard nav.
2121
+ */
2122
+ cancelInteraction() {
2123
+ // Abort the in-flight gesture AND roll `_scene` back to the pre-gesture
2124
+ // snapshot — Esc during any drag/move/resize/endpoint-rebind restores the
2125
+ // scene to exactly where it was (cancelling the history tx alone wouldn't).
2126
+ this.cancelGesture();
2127
+ this.actor.send({ type: "POINTER_CANCEL" });
2128
+ this.drawingPreview = null;
2129
+ this.edgePreview = null;
2130
+ this.lassoPreview = null;
2131
+ // Abort a host-managed link-from-anchor gesture too — it lives outside
2132
+ // the machine, so POINTER_CANCEL above doesn't touch it. Without this a
2133
+ // gesture left mid-flight would keep its preview after Escape.
2134
+ this.linkDragFromAnchor = null;
2135
+ this.hoveredLinkTarget = null;
2136
+ this.hoverCursorWorld = null;
2137
+ this._editingLinkCaption = null;
2138
+ this.pendingLinkDropMenu = null;
2139
+ this.linkWaypointDrag = null;
2140
+ this.linkSegmentDrag = null;
2141
+ // Endpoint-rebind drag: gestureTx.cancel above already reverted the live
2142
+ // re-point; just drop the handle-preview state so the dot stops tracking.
2143
+ this.linkEndpointDrag = null;
2144
+ // Esc exits group-isolation if active. The selection that was
2145
+ // active inside the group is dropped (Esc reads as a full
2146
+ // "back out" — selecting the group is a separate gesture).
2147
+ if (this._enteredGroup !== null) {
2148
+ this._enteredGroup = null;
2149
+ }
2150
+ this._selection = Selection.EMPTY;
2151
+ this._selectedLinks = LinkSelection.EMPTY;
2152
+ this.notify();
2153
+ this.announce("Selection cleared");
2154
+ }
2155
+ /**
2156
+ * Duplicate the selected shapes 10 px down-right of the originals.
2157
+ * Links between selected shapes are NOT cloned. Single undo step.
2158
+ */
2159
+ // Pure body in `./editor/public/selection-ops.ts`.
2160
+ duplicateSelected() {
2161
+ const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
2162
+ if (!result)
2163
+ return;
2164
+ const tx = this._history.transaction();
2165
+ this._scene = result.scene;
2166
+ for (const patch of result.patches)
2167
+ tx.add(patch);
2168
+ tx.commit();
2169
+ if (result.newIds.length > 0) {
2170
+ this._selection = selectionFromNewIds(result.newIds);
2171
+ }
2172
+ this.notify();
2173
+ }
2174
+ /**
2175
+ * Clone the selection IN PLACE (zero offset), select the clones, and return
2176
+ * the clone of `anchorId` (or null). Unlike `duplicateSelected` this also
2177
+ * clones group descendants and frame members, remapping `parentId`/`frameId`
2178
+ * among the clones so a duplicated frame keeps its contents. Used by
2179
+ * `⌥`-drag duplicate — the caller then drags the clones, leaving the
2180
+ * originals. One undo step.
2181
+ */
2182
+ duplicateSelectedInPlace(anchorId = null) {
2183
+ if (this._selection.size === 0)
2184
+ return null;
2185
+ // Expand: selection + group descendants (parentId) + frame members (frameId).
2186
+ const ids = new Set();
2187
+ const addWithDescendants = (id) => {
2188
+ if (ids.has(id))
2189
+ return;
2190
+ ids.add(id);
2191
+ for (const s of this._scene.elements.values()) {
2192
+ if (s.parentId === id || s.frameId === id)
2193
+ addWithDescendants(s.id);
2194
+ }
2195
+ };
2196
+ for (const id of this._selection)
2197
+ addWithDescendants(id);
2198
+ // Pre-allocate new ids so cross-references (parentId/frameId) can be remapped.
2199
+ const idMap = new Map();
2200
+ for (const id of ids)
2201
+ idMap.set(id, castElementId(this.uniqueId("shape")));
2202
+ const tx = this._history.transaction();
2203
+ for (const id of ids) {
2204
+ const shape = getElement(this._scene, id);
2205
+ if (!shape)
2206
+ continue;
2207
+ const newId = idMap.get(id);
2208
+ if (newId === undefined)
2209
+ continue;
2210
+ const order = orderForTop([...this._scene.elements.values()]
2211
+ .filter((sh) => sh.layerId === shape.layerId)
2212
+ .map((sh) => sh.order));
2213
+ const copy = { ...shape, id: newId, order };
2214
+ if (copy.parentId !== undefined) {
2215
+ const mapped = idMap.get(copy.parentId);
2216
+ if (mapped !== undefined)
2217
+ copy.parentId = mapped;
2218
+ }
2219
+ if (copy.frameId !== undefined) {
2220
+ const mapped = idMap.get(copy.frameId);
2221
+ if (mapped !== undefined)
2222
+ copy.frameId = mapped;
2223
+ }
2224
+ const r = addElement(this._scene, copy);
2225
+ this._scene = r.scene;
2226
+ tx.add(r.patch);
2227
+ }
2228
+ tx.commit();
2229
+ // Select the clones of the originally-selected ids.
2230
+ const selectedClones = [];
2231
+ for (const id of this._selection) {
2232
+ const c = idMap.get(id);
2233
+ if (c !== undefined)
2234
+ selectedClones.push(c);
2235
+ }
2236
+ if (selectedClones.length > 0)
2237
+ this._selection = selectionFromNewIds(selectedClones);
2238
+ this.notify();
2239
+ return anchorId !== null ? (idMap.get(anchorId) ?? null) : null;
2240
+ }
2241
+ setSelection(ids) {
2242
+ const next = computeSetSelection(this._scene, ids, this._selection);
2243
+ if (!next)
2244
+ return;
2245
+ this._selection = next;
2246
+ if (this._selectedLinks.size > 0)
2247
+ this._selectedLinks = LinkSelection.EMPTY;
2248
+ this.notify();
2249
+ }
2250
+ selectAll() {
2251
+ const next = computeSelectAll(this._scene, this._selection);
2252
+ const nextLinks = computeSelectAllLinks(this._scene);
2253
+ const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
2254
+ // `computeSelectAll` returns null when the element set is unchanged;
2255
+ // still proceed if the link set changed (e.g. only links left to add).
2256
+ if (!next && !linksChanged)
2257
+ return;
2258
+ if (next)
2259
+ this._selection = next;
2260
+ this._selectedLinks = nextLinks;
2261
+ this.notify();
2262
+ const count = this._selection.size + this._selectedLinks.size;
2263
+ this.announce(`Selected ${count} objects`);
2264
+ }
2265
+ /**
2266
+ * Internal clipboard. Stored as deep-cloned snapshots so subsequent
2267
+ * mutations don't affect the buffer. Survives across editor calls
2268
+ * within the same session; cross-tab paste uses host-level
2269
+ * `navigator.clipboard` (out of scope for the editor).
2270
+ */
2271
+ clipboard = [];
2272
+ // Pure body in `./editor/public/clipboard.ts`.
2273
+ copySelected() {
2274
+ const out = copySelectedPure(this._scene, this._selection);
2275
+ if (out.length === 0)
2276
+ return;
2277
+ this.clipboard = [...out];
2278
+ this.announce(`Copied ${out.length} shapes`);
2279
+ }
2280
+ cutSelected() {
2281
+ this.copySelected();
2282
+ this.deleteSelected();
2283
+ }
2284
+ /**
2285
+ * Paste clipboard contents into the scene. The cluster lands so that
2286
+ * its centroid sits at `targetWorld` (defaults to the last tracked
2287
+ * cursor position; when even that is unavailable, falls back to a
2288
+ * +10 px nudge so duplicates stay visible). Relative offsets
2289
+ * between clipboard items are preserved.
2290
+ *
2291
+ * New shapes get fresh ids and end up selected. Single undo step.
2292
+ */
2293
+ // Pure body in `./editor/public/clipboard.ts`.
2294
+ paste(targetWorld) {
2295
+ if (this.clipboard.length === 0)
2296
+ return;
2297
+ // Defensive: if a gesture is mid-flight (drag / resize) the
2298
+ // gestureTx is still open and a fresh `transaction()` inside
2299
+ // pasteElements would throw. Reasonable behaviour for a user
2300
+ // pressing Cmd+V mid-gesture is "commit what you have and
2301
+ // paste on top", so close the gesture first.
2302
+ this.finalizeOpenGestureTx();
2303
+ const target = targetWorld ?? this.lastPointerWorld;
2304
+ const result = pasteFromClipboard(this._scene, this._history, this.clipboard, target ?? null, () => ++this.nextId);
2305
+ this._scene = result.scene;
2306
+ this._selection = selectionFromPasted(result.newIds);
2307
+ this.notify();
2308
+ this.announce(`Pasted ${result.newIds.length} shapes`);
2309
+ }
2310
+ /**
2311
+ * Merge `partial` into the `style` of every shape in `ids`. Useful
2312
+ * for the inspector / PropertyPanel: flipping `roundness`, swapping
2313
+ * `lineJoin`, changing `stroke` colour across a multi-selection,
2314
+ * etc. All changes go through one history record (single undo).
2315
+ *
2316
+ * No-op when `ids` is empty or none of the targeted shapes exist.
2317
+ */
2318
+ // Pure body in `./editor/public/selection-ops.ts`.
2319
+ updateStyle(ids, partial) {
2320
+ const result = computeUpdateStyle(this._scene, ids, partial);
2321
+ if (!result)
2322
+ return;
2323
+ this._scene = result.scene;
2324
+ this._history.push(result.patch);
2325
+ this.notify();
2326
+ }
2327
+ /**
2328
+ * Update non-style text properties (`fontSize`, `fontFamily`,
2329
+ * `maxWidth`) on every selected text shape. Non-text shapes are
2330
+ * skipped. Single undo step. Used by the text contextual panel.
2331
+ */
2332
+ updateTextProps(ids, partial) {
2333
+ const result = computeUpdateTextProps(this._scene, ids, partial);
2334
+ if (!result)
2335
+ return;
2336
+ this._scene = result.scene;
2337
+ this._history.push(result.patch);
2338
+ this.notify();
2339
+ }
2340
+ /**
2341
+ * Set (or clear, with `null`) the element-level hyperlink (`href`) on
2342
+ * every shape in `ids`. Single undo step. Pass a raw user string — it
2343
+ * is normalised here (`normalizeHref`: adds `https://`, `mailto:`,
2344
+ * rejects `javascript:`/`data:`); a string that normalises to nothing
2345
+ * clears the link. The host opens it on Cmd/Ctrl-click or the
2346
+ * hover link-popup.
2347
+ */
2348
+ setLink(ids, href) {
2349
+ const normalized = href === null ? null : normalizeHref(href);
2350
+ const result = computeSetLink(this._scene, ids, normalized);
2351
+ if (!result)
2352
+ return;
2353
+ this._scene = result.scene;
2354
+ this._history.push(result.patch);
2355
+ this.notify();
2356
+ }
2357
+ /**
2358
+ * Open an element hyperlink in a new tab. Re-validates the scheme
2359
+ * (`safeHref`) before navigating — only `http`/`https`/`mailto`, never
2360
+ * `javascript:` / `data:` — and uses `noopener,noreferrer`. No-op for
2361
+ * an unsafe / empty href or outside a browser.
2362
+ */
2363
+ openLink(href) {
2364
+ const url = safeHref(href);
2365
+ if (!url || typeof window === "undefined")
2366
+ return;
2367
+ window.open(url, "_blank", "noopener,noreferrer");
2368
+ }
2369
+ /** The element-level href of a shape, if safe to open; else `null`. */
2370
+ elementLink(id) {
2371
+ return safeHref(getElement(this._scene, id)?.href);
2372
+ }
2373
+ /**
2374
+ * Topmost interactable shape at `worldPoint` that carries a safe link,
2375
+ * with its world bounds — for the hover link-popup. `null` when none.
2376
+ */
2377
+ linkAt(worldPoint) {
2378
+ const shape = this.acceleratedElementAt(worldPoint);
2379
+ if (!shape || !this.isElementInteractable(shape))
2380
+ return null;
2381
+ const href = safeHref(shape.href);
2382
+ if (!href)
2383
+ return null;
2384
+ return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
2385
+ }
2386
+ // Pure bodies in `./editor/public/z-order.ts`.
2387
+ bringToFront(id) {
2388
+ const result = computeBringToFront(this._scene, id, this._selection);
2389
+ if (!result)
2390
+ return;
2391
+ this._scene = result.scene;
2392
+ this._history.push(result.patch);
2393
+ this.notify();
2394
+ }
2395
+ sendToBack(id) {
2396
+ const result = computeSendToBack(this._scene, id, this._selection);
2397
+ if (!result)
2398
+ return;
2399
+ this._scene = result.scene;
2400
+ this._history.push(result.patch);
2401
+ this.notify();
2402
+ }
2403
+ /** Move the target shape one step toward the top of its layer. */
2404
+ bringForward(id) {
2405
+ const result = computeBringForward(this._scene, id, this._selection);
2406
+ if (!result)
2407
+ return;
2408
+ this._scene = result.scene;
2409
+ this._history.push(result.patch);
2410
+ this.notify();
2411
+ }
2412
+ /** Move the target shape one step toward the bottom of its layer. */
2413
+ sendBackward(id) {
2414
+ const result = computeSendBackward(this._scene, id, this._selection);
2415
+ if (!result)
2416
+ return;
2417
+ this._scene = result.scene;
2418
+ this._history.push(result.patch);
2419
+ this.notify();
2420
+ }
2421
+ compactLayerZOrder(layerId, options = {}) {
2422
+ const recordHistory = options.recordHistory ?? true;
2423
+ const layerIds = layerId ? [layerId] : [...this._scene.layers.keys()];
2424
+ const tx = recordHistory ? this._history.transaction() : null;
2425
+ const touched = compactLayerZOrderPatches(this._scene, layerIds, (nextScene, patch) => {
2426
+ this._scene = nextScene;
2427
+ tx?.add(patch);
2428
+ });
2429
+ if (touched === 0) {
2430
+ tx?.cancel();
2431
+ return;
2432
+ }
2433
+ tx?.commit();
2434
+ this.notify();
2435
+ if (recordHistory) {
2436
+ this.announce(`Compacted z-order across ${layerIds.length} layer(s)`);
2437
+ }
2438
+ }
2439
+ /**
2440
+ * Wipe every shape + edge from the scene. Layers and viewport survive.
2441
+ * Clears history — restoring an empty scene through undo would be
2442
+ * surprising and the operation is rarely chained with other edits.
2443
+ */
2444
+ clear() {
2445
+ if (this._scene.elements.size === 0 && this._scene.links.size === 0)
2446
+ return;
2447
+ this._scene = {
2448
+ ...this._scene,
2449
+ elements: new Map(),
2450
+ links: new Map(),
2451
+ };
2452
+ this._selection = Selection.EMPTY;
2453
+ this._selectedLinks = LinkSelection.EMPTY;
2454
+ this._history.clear();
2455
+ this.notify();
2456
+ }
2457
+ // --- Layer commands ---
2458
+ /** Currently active layer — new shapes default into it. */
2459
+ get activeLayerId() {
2460
+ return this._activeLayerId;
2461
+ }
2462
+ /** Switch the active layer. Hosts call this from a layer panel click. */
2463
+ setActiveLayer(id) {
2464
+ if (!this._scene.layers.has(id))
2465
+ return;
2466
+ if (this._activeLayerId === id)
2467
+ return;
2468
+ this._activeLayerId = id;
2469
+ this.notify();
2470
+ }
2471
+ // Pure bodies in `./editor/public/layers.ts`.
2472
+ createLayer(name) {
2473
+ const result = computeCreateLayer(this._scene, name, newLayerId(++this.nextId));
2474
+ this._scene = result.scene;
2475
+ this._history.push(result.patch);
2476
+ this._activeLayerId = result.layerId;
2477
+ this.notify();
2478
+ return result.layerId;
2479
+ }
2480
+ removeLayer(id) {
2481
+ const result = computeRemoveLayer(this._scene, id, this._activeLayerId);
2482
+ if (!result)
2483
+ return;
2484
+ const tx = this._history.transaction();
2485
+ this._scene = result.scene;
2486
+ for (const patch of result.patches)
2487
+ tx.add(patch);
2488
+ tx.commit();
2489
+ this._activeLayerId = result.nextActiveLayerId;
2490
+ this._selection = Selection.EMPTY;
2491
+ this.notify();
2492
+ }
2493
+ renameLayer(id, name) {
2494
+ const result = computeRenameLayer(this._scene, id, name);
2495
+ if (!result)
2496
+ return;
2497
+ this._scene = result.scene;
2498
+ this._history.push(result.patch);
2499
+ this.notify();
2500
+ }
2501
+ toggleLayerVisibility(id) {
2502
+ const result = computeToggleLayerVisibility(this._scene, id);
2503
+ if (!result)
2504
+ return;
2505
+ this._scene = result.scene;
2506
+ this._history.push(result.patch);
2507
+ this.notify();
2508
+ }
2509
+ toggleLayerLock(id) {
2510
+ const result = computeToggleLayerLock(this._scene, id);
2511
+ if (!result)
2512
+ return;
2513
+ this._scene = result.scene;
2514
+ this._history.push(result.patch);
2515
+ this.notify();
2516
+ }
2517
+ moveSelectionToLayer(targetLayer) {
2518
+ const result = computeMoveSelectionToLayer(this._scene, this._selection, targetLayer);
2519
+ if (!result)
2520
+ return;
2521
+ const tx = this._history.transaction();
2522
+ this._scene = result.scene;
2523
+ for (const patch of result.patches)
2524
+ tx.add(patch);
2525
+ tx.commit();
2526
+ this.notify();
2527
+ }
2528
+ // --- Viewport commands ---
2529
+ /**
2530
+ * Pan the camera by a screen-space delta. Drives both wheel-pan and
2531
+ * the two-finger pan gesture; deltas are in screen pixels (positive
2532
+ * x → shapes move right relative to the user). Not recorded in
2533
+ * history — viewport state is editor-local.
2534
+ */
2535
+ // Pure bodies in `./editor/public/zoom-pan.ts`.
2536
+ panBy(deltaScreen) {
2537
+ const next = computePan(this._scene, deltaScreen);
2538
+ if (!next)
2539
+ return;
2540
+ this._scene = next;
2541
+ this.notify();
2542
+ }
2543
+ zoomIn() {
2544
+ this.zoomStep(WHEEL_ZOOM_STEP);
2545
+ }
2546
+ zoomOut() {
2547
+ this.zoomStep(1 / WHEEL_ZOOM_STEP);
2548
+ }
2549
+ zoomStep(factor) {
2550
+ const vp = this._scene.viewport;
2551
+ if (vp.size.width <= 0 || vp.size.height <= 0)
2552
+ return;
2553
+ const center = this.screenToWorld({ x: vp.size.width / 2, y: vp.size.height / 2 });
2554
+ this.zoomAt(factor, center);
2555
+ }
2556
+ resetZoom() {
2557
+ const next = computeResetZoom(this._scene);
2558
+ if (!next)
2559
+ return;
2560
+ this._scene = next;
2561
+ this.notify();
2562
+ }
2563
+ zoomToFit(padding = 40) {
2564
+ const next = computeZoomToFit(this._scene, padding);
2565
+ if (!next)
2566
+ return;
2567
+ this._scene = next;
2568
+ this.notify();
2569
+ }
2570
+ /** Fit the camera to the current selection (standard `⌥2`). No-op when empty. */
2571
+ zoomToSelection(padding = 80) {
2572
+ const bounds = this.combinedSelectionBounds();
2573
+ if (!bounds)
2574
+ return;
2575
+ const next = computeZoomToBounds(this._scene, bounds, padding);
2576
+ if (!next)
2577
+ return;
2578
+ this._scene = next;
2579
+ this.notify();
2580
+ }
2581
+ /**
2582
+ * Select the nearest interactable top-level element in `direction` from the
2583
+ * current selection's centre (or the viewport centre when nothing is
2584
+ * selected). standard `⌘`+arrows. Candidates must lie within a 45° cone of the
2585
+ * direction; the closest by along+lateral distance wins. No-op when nothing
2586
+ * qualifies.
2587
+ */
2588
+ selectClosest(direction) {
2589
+ const ref = this.combinedSelectionBounds();
2590
+ const vp = this._scene.viewport;
2591
+ const refC = ref
2592
+ ? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
2593
+ : {
2594
+ x: vp.pan.x + vp.size.width / 2 / vp.zoom,
2595
+ y: vp.pan.y + vp.size.height / 2 / vp.zoom,
2596
+ };
2597
+ const dv = direction === "left"
2598
+ ? { x: -1, y: 0 }
2599
+ : direction === "right"
2600
+ ? { x: 1, y: 0 }
2601
+ : direction === "up"
2602
+ ? { x: 0, y: -1 }
2603
+ : { x: 0, y: 1 };
2604
+ let best = null;
2605
+ let bestScore = Infinity;
2606
+ for (const s of this._scene.elements.values()) {
2607
+ if (s.parentId !== undefined)
2608
+ continue; // top-level shapes only
2609
+ if (this._selection.has(s.id))
2610
+ continue;
2611
+ if (!this.isElementInteractable(s))
2612
+ continue;
2613
+ const b = getElementWorldBounds(s);
2614
+ const cx = b.x + b.width / 2 - refC.x;
2615
+ const cy = b.y + b.height / 2 - refC.y;
2616
+ const along = cx * dv.x + cy * dv.y;
2617
+ if (along <= 0)
2618
+ continue; // not in the direction's half-plane
2619
+ const perp = Math.abs(cx * dv.y - cy * dv.x);
2620
+ if (perp > along)
2621
+ continue; // outside the 45° cone
2622
+ const score = along + perp;
2623
+ if (score < bestScore) {
2624
+ bestScore = score;
2625
+ best = s.id;
2626
+ }
2627
+ }
2628
+ if (best === null)
2629
+ return;
2630
+ this.setSelection([best]);
2631
+ }
2632
+ zoomAt(factor, anchorWorld) {
2633
+ const next = computeZoomAt(this._scene, factor, anchorWorld);
2634
+ if (!next)
2635
+ return;
2636
+ this._scene = next;
2637
+ this.notify();
2638
+ }
2639
+ setViewportSize(width, height) {
2640
+ const next = computeViewportResize(this._scene, width, height);
2641
+ if (!next)
2642
+ return;
2643
+ this._scene = next;
2644
+ this.notify();
2645
+ }
2646
+ setGrid(patch) {
2647
+ const next = computeSetGrid(this._scene, patch);
2648
+ if (!next)
2649
+ return;
2650
+ this._scene = next;
2651
+ this.notify();
2652
+ }
2653
+ /** Whether snap-to-grid is currently enabled (default on). */
2654
+ get snapToGridEnabled() {
2655
+ return isSnapToGridEnabled(this._scene.viewport);
2656
+ }
2657
+ /** Toggle snap-to-grid on/off. Persists in the viewport. */
2658
+ setSnapToGrid(enabled) {
2659
+ this.setGrid({ snap: enabled });
2660
+ }
2661
+ /**
2662
+ * Host hook: while held, the next move/resize/create gesture ignores
2663
+ * snap-to-grid (Cmd/Ctrl modifier). The app wires keydown/keyup
2664
+ * of the modifier to this. Idempotent; never touches history.
2665
+ */
2666
+ setSnapSuppressed(suppressed) {
2667
+ this.snapSuppressed = suppressed;
2668
+ }
2669
+ /**
2670
+ * True when a gesture should snap: feature on, grid visible, AND the
2671
+ * suppress modifier not held. Hiding the grid (`toggleGrid` / `g`) also
2672
+ * disables snap-to-grid — snapping to an invisible grid is confusing.
2673
+ * Re-showing the grid restores the stored snap preference.
2674
+ */
2675
+ snapActive() {
2676
+ return !this.snapSuppressed && this.gridVisible && isSnapToGridEnabled(this._scene.viewport);
2677
+ }
2678
+ /** World-unit spacing the current gesture snaps to. */
2679
+ snapSpacing() {
2680
+ return resolveSnapSpacing(this._scene.viewport);
2681
+ }
2682
+ /**
2683
+ * Replace the entire scene (e.g. after `parseScene`). Clears history,
2684
+ * selection and any open gesture. Use to load a saved document.
2685
+ */
2686
+ loadScene(scene, options = {}) {
2687
+ if (this.gestureTx) {
2688
+ this.gestureTx.cancel();
2689
+ this.gestureTx = null;
2690
+ }
2691
+ this._scene = scene;
2692
+ // Snap active layer back into the loaded scene's layer set.
2693
+ if (!scene.layers.has(this._activeLayerId)) {
2694
+ const first = scene.layers.keys().next().value;
2695
+ this._activeLayerId = first ?? castLayerId(DEFAULT_LAYER_ID);
2696
+ }
2697
+ if (options.preserveHistory) {
2698
+ // Used by collab when a peer update arrives — the local user's
2699
+ // undo stack must survive remote edits. Drop selection entries
2700
+ // that no longer point to existing shapes; the rest of the stack
2701
+ // stays untouched (patches that reference removed shapes will
2702
+ // throw on `apply` and need user-visible recovery later).
2703
+ this.pruneSelection();
2704
+ }
2705
+ else {
2706
+ this._selection = Selection.EMPTY;
2707
+ this._history.clear();
2708
+ }
2709
+ // Restore transient animationData (GIF bytes) from Scene.files
2710
+ // before the tick so the animation adapter can decode frames.
2711
+ this.rehydrateAnimatedImages();
2712
+ this.notify();
2713
+ // Loaded scene may carry animated shapes (e.g. GIF re-imported
2714
+ // from saved JSON). Re-arm the tick — `metadata.animated` survives
2715
+ // serialisation and `rehydrateAnimatedImages` re-attached the
2716
+ // bytes, so the registered adapter can produce frames again.
2717
+ // `maybeAnimate` honours the G1 viewport cull.
2718
+ this.maybeAnimate();
2719
+ }
2720
+ /** Detach all DOM listeners and stop the actor. */
2721
+ dispose() {
2722
+ this.cancelLongPress();
2723
+ this.unbind();
2724
+ this.actor.stop();
2725
+ this.listeners.clear();
2726
+ this.cursorListeners.clear();
2727
+ this.longPressListeners.clear();
2728
+ this.announceListeners.clear();
2729
+ this.animationTick.stop();
2730
+ this.animationContentOff?.();
2731
+ if (typeof document !== "undefined") {
2732
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
2733
+ }
2734
+ if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
2735
+ cancelAnimationFrame(this.renderRafId);
2736
+ this.renderRafId = null;
2737
+ }
2738
+ }
2739
+ // --- Internal ---
2740
+ // Body moved to `./editor/pointer-binding.ts` (~700 lines of
2741
+ // pointer / wheel / keyboard dispatch). The thin wrapper here
2742
+ // preserves the original constructor call site.
2743
+ bindPointerEvents() {
2744
+ return bindPointerEventsExternal(this);
2745
+ }
2746
+ /**
2747
+ * Open a pan gesture: capture the pointer so subsequent move / up
2748
+ * events arrive even outside the host bounds, cancel anything the
2749
+ * machine might have started this tick, and switch the cursor.
2750
+ */
2751
+ beginPanGesture(pointerId, button, point) {
2752
+ this.actor.send({ type: "POINTER_CANCEL" });
2753
+ this.cancelGesture();
2754
+ this.cancelLongPress();
2755
+ this.host.setPointerCapture(pointerId);
2756
+ this.panGesture = {
2757
+ pointerId,
2758
+ button,
2759
+ startPoint: point,
2760
+ lastPoint: point,
2761
+ moved: false,
2762
+ };
2763
+ this.refreshCursor(); // → "grabbing" while panGesture is set
2764
+ }
2765
+ /**
2766
+ * End an in-progress pan gesture. Restores the cursor unless Space
2767
+ * is still held (then we drop back to "grab" so the user knows
2768
+ * another drag is armed). For right-click that didn't move past
2769
+ * the slop threshold, fires the long-press callback so the context
2770
+ * menu opens at the click position — that's the "right-click =
2771
+ * menu, right-drag = pan" decision rule.
2772
+ */
2773
+ endPanGesture() {
2774
+ const gesture = this.panGesture;
2775
+ this.panGesture = null;
2776
+ if (gesture && (gesture.button === 2 || gesture.button === 1) && !gesture.moved) {
2777
+ // Right-click without a drag → trigger the context-menu listeners.
2778
+ // Same payload as touch long-press so existing UI (e.g.
2779
+ // `@react-ui/ContextMenu`) works without changes.
2780
+ const worldPoint = this.screenToWorld(gesture.startPoint);
2781
+ for (const fn of this.longPressListeners) {
2782
+ fn({ screenPoint: gesture.startPoint, worldPoint });
2783
+ }
2784
+ }
2785
+ else {
2786
+ // Either it was a real drag, or Space + left drag. In both cases the
2787
+ // native context menu stays suppressed until the upcoming
2788
+ // `contextmenu` event lands (Chrome fires it after pointerup on the
2789
+ // right button).
2790
+ }
2791
+ // Pan over — recompute (→ "grab" if Space/hand still armed, else the
2792
+ // idle hover cursor).
2793
+ this.refreshCursor();
2794
+ }
2795
+ isDrawingPhase(ctx) {
2796
+ return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
2797
+ }
2798
+ // --- Long-press --- (controller in `./editor/long-press.ts`)
2799
+ startLongPress(screenPoint) {
2800
+ this.longPress.start(screenPoint);
2801
+ }
2802
+ cancelLongPress() {
2803
+ this.longPress.cancel();
2804
+ }
2805
+ // --- Pinch gesture --- (controller in `./editor/pinch.ts`)
2806
+ beginPinch() {
2807
+ this.pinch.begin([...this.activePointers.values()]);
2808
+ }
2809
+ applyPinch() {
2810
+ this.pinch.apply([...this.activePointers.values()]);
2811
+ }
2812
+ /**
2813
+ * Convert a point in the host element's CSS-pixel coordinate space into
2814
+ * world coordinates. Public so drop handlers (drag-from-palette, paste)
2815
+ * can map pointer positions back to scene space.
2816
+ */
2817
+ screenToWorld(point) {
2818
+ return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
2819
+ }
2820
+ // Pure body in `./editor/hit-test.ts`. Editor passes a narrow
2821
+ // context bundle that closes over its private state + accel
2822
+ // helpers (acceleratedElementAt, isElementInteractable, …).
2823
+ /**
2824
+ * Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
2825
+ * interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
2826
+ * link's own endpoint handle. Used instead of {@link hitTest} when finishing
2827
+ * an endpoint drag — `hitTest` would return the (now live, cursor-tracking)
2828
+ * endpoint handle and shadow the element beneath it, blocking re-binding.
2829
+ * `undefined` → dropped on empty space (the end stays a free point).
2830
+ */
2831
+ linkAttachTargetAt(worldPoint) {
2832
+ const shape = this.acceleratedElementAt(worldPoint);
2833
+ if (shape && this.isElementInteractable(shape)) {
2834
+ const target = this.promoteToGroupRoot(shape);
2835
+ return { kind: "element", id: target.id, bounds: getElementWorldBounds(target) };
2836
+ }
2837
+ return undefined;
2838
+ }
2839
+ hitTest(worldPoint) {
2840
+ return pickPressTarget(worldPoint, {
2841
+ scene: this._scene,
2842
+ selection: this._selection,
2843
+ selectedLink: this.selectedLink,
2844
+ selectedLinkCount: this._selectedLinks.size,
2845
+ enteredGroup: this._enteredGroup,
2846
+ handleHitSlop: this.handleHitSlop,
2847
+ edgeHandleHitSlop: this.edgeHandleHitSlop,
2848
+ edgeHitThreshold: this.edgeHitThreshold,
2849
+ hitAnnotation: (p) => this.hitAnnotation(p),
2850
+ selectionIsAspectLocked: () => this.selectionIsAspectLocked(),
2851
+ combinedSelectionBounds: () => this.combinedSelectionBounds(),
2852
+ acceleratedElementAt: (p) => this.acceleratedElementAt(p),
2853
+ isElementInteractable: (s) => this.isElementInteractable(s),
2854
+ isLayerLocked: (id) => this.isLayerLocked(id),
2855
+ promoteToGroupRoot: (s) => this.promoteToGroupRoot(s),
2856
+ });
2857
+ }
2858
+ /**
2859
+ * Recompute the canvas cursor from the current interaction state and apply
2860
+ * it to the host element. Single chokepoint — called from pointer-move
2861
+ * (hover), gesture begin/end, and mode changes so the cursor never drifts
2862
+ * out of sync. `worldPoint` defaults to the last known pointer position.
2863
+ */
2864
+ refreshCursor(worldPoint) {
2865
+ const next = this.computeCursor(worldPoint ?? this.lastPointerWorld);
2866
+ if (this.host.style.cursor !== next)
2867
+ this.host.style.cursor = next;
2868
+ }
2869
+ /**
2870
+ * The CSS cursor for the current state. Priority: active gesture → text edit → pan affordance →
2871
+ * draw tool → idle hover hit-test. Pure read of editor state; no side effects.
2872
+ */
2873
+ computeCursor(p) {
2874
+ // Each outcome is a (role, fallback-keyword) pair; `resolveCursor` returns
2875
+ // a host-registered custom image for that role if one exists, else the
2876
+ // keyword. Roles are the stable override keys (see `setCursorOverride`).
2877
+ const r = (role, keyword) => this.resolveCursor(role, keyword);
2878
+ const resizeRole = (h) => r(RESIZE_ROLE[h], cursorForHandle(h));
2879
+ // 1. Active gestures (highest priority — what the pointer is doing now).
2880
+ if (this.panGesture)
2881
+ return r("pan-active", "grabbing");
2882
+ if (this.linkDragFromAnchor?.moved === true)
2883
+ return r("draw", "crosshair");
2884
+ if (this.isDraggingWaypoint || this.isDraggingSegment)
2885
+ return r("move", "grabbing");
2886
+ if (this.annotationDrag?.moved === true)
2887
+ return r("move", "grabbing");
2888
+ if (this.brushStroke)
2889
+ return r("draw", "crosshair");
2890
+ // Machine-driven drag past the threshold (`gestureTx` opens then): resize
2891
+ // shows the handle's arrow; element / link move shows grabbing.
2892
+ if (this.gestureTx) {
2893
+ const t = this.actor.getSnapshot().context.pressTarget;
2894
+ if (t && (t.kind === "handle" || t.kind === "group-handle"))
2895
+ return resizeRole(t.handle);
2896
+ if (t && (t.kind === "element" || t.kind === "link" || t.kind === "edge-endpoint")) {
2897
+ return r("move", "grabbing");
2898
+ }
2899
+ }
2900
+ // 2. In-canvas text editing → I-beam.
2901
+ if (this.editingTextElement !== null)
2902
+ return r("text", "text");
2903
+ // 3. Pan affordance (idle): Space held or hand tool.
2904
+ if (this.spaceHeld || this.mode === "hand")
2905
+ return r("pan-ready", "grab");
2906
+ // 4. Draw tools (idle, before a gesture starts).
2907
+ switch (this.mode) {
2908
+ case "draw-rect":
2909
+ case "draw-ellipse":
2910
+ case "draw-frame":
2911
+ case "draw-edge":
2912
+ case "brush":
2913
+ return r("draw", "crosshair");
2914
+ case "draw-text":
2915
+ return r("text", "text");
2916
+ default:
2917
+ break;
2918
+ }
2919
+ // 5. Idle hover in select mode — key off the hit-test target.
2920
+ if (p) {
2921
+ if (this.isOverLinkStartDot(p))
2922
+ return r("link-start", "crosshair");
2923
+ const t = this.hitTest(p);
2924
+ switch (t.kind) {
2925
+ case "handle":
2926
+ case "group-handle":
2927
+ return resizeRole(t.handle);
2928
+ case "edge-endpoint":
2929
+ return r("link-handle", "grab");
2930
+ case "annotation":
2931
+ return r("annotation", "pointer");
2932
+ default:
2933
+ return r("default", "default");
2934
+ }
2935
+ }
2936
+ return r("default", "default");
2937
+ }
2938
+ /**
2939
+ * Resolve a cursor role to a CSS `cursor` value: a host-registered custom
2940
+ * image (via {@link setCursorOverride}) if present, else `fallbackKeyword`.
2941
+ */
2942
+ resolveCursor(role, fallbackKeyword) {
2943
+ const spec = this.cursorOverrides.get(role);
2944
+ return spec === undefined ? fallbackKeyword : cssCursor(spec, fallbackKeyword);
2945
+ }
2946
+ /**
2947
+ * Register (or clear, with `null`) a custom cursor image for a state role.
2948
+ * The image is shown wherever `computeCursor` resolves that role; pass a
2949
+ * `CursorSpec` object for a DPR-aware image (`image-set(1x, 2x)`) with a
2950
+ * hotspot + keyword fallback, or a raw CSS cursor string. Host-only view
2951
+ * state — not persisted.
2952
+ */
2953
+ setCursorOverride(role, spec) {
2954
+ if (spec === null)
2955
+ this.cursorOverrides.delete(role);
2956
+ else
2957
+ this.cursorOverrides.set(role, spec);
2958
+ this.refreshCursor();
2959
+ }
2960
+ /**
2961
+ * True when `p` is within the grab radius of one of the single selected
2962
+ * element's link-start dots — used to show a `crosshair` (start a link).
2963
+ * Mirrors the anchor-drag hit-test in pointer-binding so the cursor matches
2964
+ * exactly where a press would begin a link.
2965
+ */
2966
+ isOverLinkStartDot(p) {
2967
+ if (this.mode !== "select" || this._selection.size !== 1)
2968
+ return false;
2969
+ const id = [...this._selection][0];
2970
+ if (id === undefined)
2971
+ return false;
2972
+ const shape = getElement(this._scene, id);
2973
+ if (!shape)
2974
+ return false;
2975
+ const zoom = this._scene.viewport.zoom || 1;
2976
+ const { worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
2977
+ const grab = (ANCHOR_DOT_ACTIVE_RADIUS + this.anchorStartHitSlop) / zoom;
2978
+ const grab2 = grab * grab;
2979
+ for (const wp of worldPoints) {
2980
+ const dx = wp.x - p.x;
2981
+ const dy = wp.y - p.y;
2982
+ if (dx * dx + dy * dy <= grab2)
2983
+ return true;
2984
+ }
2985
+ return false;
2986
+ }
2987
+ /** True when the given layer exists and is marked `locked`. */
2988
+ isLayerLocked(layerId) {
2989
+ const layer = this._scene.layers.get(layerId);
2990
+ return layer?.locked === true;
2991
+ }
2992
+ /**
2993
+ * Combined interactivity check: false when the shape's layer is
2994
+ * locked, or when the shape itself or any ancestor via `parentId`
2995
+ * carries `locked: true` (group lock propagation). Hit-test treats
2996
+ * non-interactable hits as misses; render still draws them so the
2997
+ * user can see what's locked.
2998
+ */
2999
+ isElementInteractable(shape) {
3000
+ if (this.isLayerLocked(shape.layerId))
3001
+ return false;
3002
+ if (isElementHidden(this._scene, shape))
3003
+ return false;
3004
+ // NOTE: a `locked` element IS interactable for SELECTION (so the user can
3005
+ // click it to unlock) — movement / resize are blocked separately via
3006
+ // `isElementManipulable`. Click-through past a locked shape is therefore
3007
+ // disabled, matching standard.
3008
+ return true;
3009
+ }
3010
+ /**
3011
+ * Can this shape be moved / resized? False when the shape (or an ancestor)
3012
+ * is `locked`, its layer is locked, or it's hidden. Distinct from
3013
+ * `isElementInteractable`, which still allows selecting a locked shape so it
3014
+ * can be unlocked.
3015
+ */
3016
+ isElementManipulable(shape) {
3017
+ if (this.isLayerLocked(shape.layerId))
3018
+ return false;
3019
+ if (isElementLocked(this._scene, shape))
3020
+ return false;
3021
+ if (isElementHidden(this._scene, shape))
3022
+ return false;
3023
+ return true;
3024
+ }
3025
+ /**
3026
+ * Toggle the `locked` flag on the selection (standard `⌘⇧L`). If any selected
3027
+ * element is currently unlocked, lock all; otherwise unlock all. One undo
3028
+ * step. A locked element stays selectable (click → select → unlock) but
3029
+ * can't be moved or resized.
3030
+ */
3031
+ toggleLockSelection() {
3032
+ if (this._selection.size === 0)
3033
+ return;
3034
+ const ids = [...this._selection];
3035
+ const anyUnlocked = ids.some((id) => getElement(this._scene, id)?.locked !== true);
3036
+ const tx = this._history.transaction();
3037
+ for (const id of ids) {
3038
+ const r = updateElement(this._scene, id, (s) => {
3039
+ const copy = { ...s };
3040
+ if (anyUnlocked)
3041
+ copy.locked = true;
3042
+ else
3043
+ delete copy.locked;
3044
+ return copy;
3045
+ });
3046
+ this._scene = r.scene;
3047
+ tx.add(r.patch);
3048
+ }
3049
+ tx.commit();
3050
+ this.notify();
3051
+ }
3052
+ /**
3053
+ * Promote a hit shape to the topmost ancestor whose group we have NOT
3054
+ * "entered" yet. Only **`group`**-typed parents promote — containers
3055
+ * (swim-lane, frame) intentionally let click hits land on their
3056
+ * children. Group is an abstract wrapper that has no visual identity,
3057
+ * so promoting up to it is the only way to select it; a container has
3058
+ * its own body, header, etc. and clicking inside it should let users
3059
+ * pick the actual child shape (rectangle, sticky, …) — same affordance
3060
+ * as standard.
3061
+ *
3062
+ * Stops at the first non-`group` parent. With `_enteredGroup` set,
3063
+ * the walk also stops just below that group so children can be edited
3064
+ * directly.
3065
+ */
3066
+ promoteToGroupRoot(shape) {
3067
+ return promoteToGroupRootHelper(this._scene, shape, this._enteredGroup);
3068
+ }
3069
+ /**
3070
+ * Topmost group ancestor of `shape` (walks parentId chain, returns
3071
+ * the highest `type === "group"` parent). `null` if `shape` has no
3072
+ * group ancestor. Used by drill-down: a double-click on a shape
3073
+ * with a group ancestor enters that group. Body extracted to
3074
+ * `./group-helpers.ts`.
3075
+ */
3076
+ topGroupAncestor(shape) {
3077
+ return topGroupAncestorHelper(this._scene, shape);
3078
+ }
3079
+ /**
3080
+ * True when `elementId`'s parent chain contains `groupId`. Used by the
3081
+ * isolation exit path: a click on a shape whose parent chain *does
3082
+ * not* lead through the entered group is a click "outside" the
3083
+ * group, which exits isolation.
3084
+ */
3085
+ isDescendantOfGroup(elementId, groupId) {
3086
+ return isDescendantOfGroupHelper(this._scene, elementId, groupId);
3087
+ }
3088
+ /**
3089
+ * Compute the dim set for isolation rendering: every shape whose
3090
+ * parent chain does NOT pass through `enteredGroupId`. The entered
3091
+ * group itself is treated as "inside" (returns true from
3092
+ * isDescendantOfGroup) so it stays at full alpha — but groups have
3093
+ * no intrinsic geometry, so this only matters for the
3094
+ * group-bounds-outline overlay path, not the shape render.
3095
+ *
3096
+ * Defensive: shapes in the current selection are never dimmed. The
3097
+ * focus shape (drilled-into child) is always a group descendant in
3098
+ * practice, but the guard keeps the contract simple — "what you've
3099
+ * selected, you can see".
3100
+ */
3101
+ // Body moved to `./editor/shape-filters.ts`.
3102
+ computeHiddenElements() {
3103
+ return computeHiddenElementsPure(this._scene);
3104
+ }
3105
+ computeDimElements(enteredGroupId) {
3106
+ return computeDimElementsHelper(this._scene, this._selection, enteredGroupId);
3107
+ }
3108
+ /**
3109
+ * Enter a group — subsequent hits inside this group return children
3110
+ * directly instead of the group root. `null` exits group-edit mode.
3111
+ * Bound to double-click on a group in the default handler.
3112
+ */
3113
+ enterGroup(groupId) {
3114
+ this._enteredGroup = groupId;
3115
+ this.notify();
3116
+ }
3117
+ /**
3118
+ * Enter the single selected container — select its contents (standard `⌘⇧↓`).
3119
+ * Members are children via `parentId` (group / template container) or via
3120
+ * `frameId` (frame). For a group we also set `enteredGroup` so subsequent
3121
+ * clicks land on children. No-op unless exactly one container with members
3122
+ * is selected.
3123
+ */
3124
+ enterContainer() {
3125
+ if (this._selection.size !== 1)
3126
+ return;
3127
+ const id = req([...this._selection][0]);
3128
+ const el = getElement(this._scene, id);
3129
+ if (!el)
3130
+ return;
3131
+ const isFrameEl = el.type === "frame";
3132
+ const members = [];
3133
+ for (const s of this._scene.elements.values()) {
3134
+ if (s.parentId === id || (isFrameEl && s.frameId === id))
3135
+ members.push(s.id);
3136
+ }
3137
+ if (members.length === 0)
3138
+ return;
3139
+ if (el.type === "group")
3140
+ this._enteredGroup = id;
3141
+ this.setSelection(members);
3142
+ }
3143
+ /**
3144
+ * Exit to the container of the current selection — select the parent group /
3145
+ * template container (`parentId`) or frame (`frameId`) when every selected
3146
+ * element shares one (standard `⌘⇧↑`). Clears `enteredGroup`. No-op when there
3147
+ * is no single common container.
3148
+ */
3149
+ exitContainer() {
3150
+ if (this._selection.size === 0)
3151
+ return;
3152
+ let parent;
3153
+ let common = true;
3154
+ for (const sid of this._selection) {
3155
+ const s = getElement(this._scene, sid);
3156
+ const p = s?.parentId ?? s?.frameId;
3157
+ if (p === undefined) {
3158
+ common = false;
3159
+ break;
3160
+ }
3161
+ if (parent === undefined)
3162
+ parent = p;
3163
+ else if (parent !== p) {
3164
+ common = false;
3165
+ break;
3166
+ }
3167
+ }
3168
+ this._enteredGroup = null;
3169
+ if (common && parent !== undefined)
3170
+ this.setSelection([parent]);
3171
+ else
3172
+ this.notify();
3173
+ }
3174
+ /** Currently "entered" group, if any. */
3175
+ get enteredGroup() {
3176
+ return this._enteredGroup;
3177
+ }
3178
+ /**
3179
+ * SpatialGrid-accelerated topmost-shape lookup. Linear scan for small
3180
+ * scenes; for larger scenes builds a grid lazily, keyed by current
3181
+ * scene-identity. Scene operations replace `_scene` (immutable patches),
3182
+ * so reference-equality is a sufficient invalidation signal.
3183
+ */
3184
+ acceleratedElementAt(worldPoint) {
3185
+ if (this._scene.elements.size < LARGE_SCENE_HIT_THRESHOLD) {
3186
+ return getElementAt(this._scene, worldPoint);
3187
+ }
3188
+ return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint);
3189
+ }
3190
+ /**
3191
+ * Build (or return the cached) `SpatialGrid` for the current scene.
3192
+ * Re-built only when `_scene` reference changes — scene operations
3193
+ * always produce a fresh object, so reference equality is a
3194
+ * sufficient invalidation signal.
3195
+ *
3196
+ * Shared between the hit-test path (`acceleratedElementAt`) and the
3197
+ * renderer pass (passed to `renderScene` as `spatialIndex`), so
3198
+ * the grid build cost is amortised across both consumers.
3199
+ */
3200
+ ensureSpatialIndex() {
3201
+ const cached = this.spatialIndexCache;
3202
+ if (cached?.scene === this._scene)
3203
+ return cached.index;
3204
+ const index = buildSpatialIndex(this._scene);
3205
+ this.spatialIndexCache = { scene: this._scene, index };
3206
+ return index;
3207
+ }
3208
+ /**
3209
+ * Group-isolation click routing. Returns `true` if the click was
3210
+ * handled (caller should skip the default applyEmit), `false` if the
3211
+ * normal selection emit should still run.
3212
+ *
3213
+ * Three paths fire here:
3214
+ * 1. **Double-click on a grouped shape (not yet in isolation):**
3215
+ * enter that group; select the raw inner shape (bypassing the
3216
+ * group-root promotion that ran in hitTest).
3217
+ * 2. **Inside isolation, click on a non-descendant shape OR empty
3218
+ * space:** exit isolation. Let the normal click then run so the
3219
+ * newly clicked element / empty selection takes hold.
3220
+ * 3. **Inside isolation, double-click on the entered group's own
3221
+ * child group:** drill another level deeper. (Implicit: same as
3222
+ * case 1 but topGroupAncestor here returns the inner child
3223
+ * group because the outer group is already entered.)
3224
+ *
3225
+ * Side-effect: updates `lastClickAt` / `lastClickWorldPoint`
3226
+ * regardless of result, so subsequent calls can detect a double-
3227
+ * click against this event.
3228
+ */
3229
+ routeIsolationClick(clickEffect, worldPoint) {
3230
+ const now = performance.now();
3231
+ const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
3232
+ this.lastClickWorldPoint !== null &&
3233
+ distanceTo(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
3234
+ this.lastClickAt = now;
3235
+ this.lastClickWorldPoint = worldPoint;
3236
+ // Double-click the frame HEADER (label strip above the body) → rename.
3237
+ // Checked before the clickEffect gate because the header sits outside
3238
+ // the frame's hit-test bounds, so the click produces SELECT_CLEAR (or
3239
+ // no effect), not a frame select.
3240
+ if (isDouble) {
3241
+ const headerFrame = this.frameHeaderAt(worldPoint);
3242
+ if (headerFrame !== null) {
3243
+ this.beginFrameNameEdit(headerFrame);
3244
+ return true;
3245
+ }
3246
+ }
3247
+ if (!clickEffect)
3248
+ return false;
3249
+ // Click outside the entered group while in isolation → exit; let
3250
+ // the normal click effect run after.
3251
+ if (this._enteredGroup !== null) {
3252
+ const targetId = clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE"
3253
+ ? clickEffect.id
3254
+ : null;
3255
+ const stillInside = targetId !== null && this.isDescendantOfGroup(targetId, this._enteredGroup);
3256
+ if (!stillInside) {
3257
+ this._enteredGroup = null;
3258
+ this.notify();
3259
+ // Fall through — apply the normal click effect (caller).
3260
+ return false;
3261
+ }
3262
+ }
3263
+ // Double-click handling for SELECT_REPLACE / SELECT_TOGGLE
3264
+ // effects. Two outcomes, in priority order:
3265
+ // 1) text shape → open inline text editor (highest priority —
3266
+ // double-clicking text in any editor means "edit the body");
3267
+ // 2) shape with a group ancestor → drill into that group.
3268
+ // Lasso / edge ops are not double-click candidates and fall
3269
+ // through to the normal single-click handler.
3270
+ // Double-click on a link → edit its caption inline (standard).
3271
+ if (isDouble && clickEffect.type === "SELECT_EDGE_REPLACE") {
3272
+ this._selectedLinks = LinkSelection.single(clickEffect.id);
3273
+ this._selection = Selection.EMPTY;
3274
+ this.beginLinkCaptionEdit(clickEffect.id);
3275
+ return true;
3276
+ }
3277
+ if (isDouble &&
3278
+ (clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
3279
+ const raw = this.acceleratedElementAt(worldPoint);
3280
+ if (raw?.type === "text") {
3281
+ this.beginTextEdit(raw.id);
3282
+ return true;
3283
+ }
3284
+ if (raw?.type === "frame") {
3285
+ this.beginFrameNameEdit(raw.id);
3286
+ return true;
3287
+ }
3288
+ if (raw) {
3289
+ const top = this.topGroupAncestor(raw);
3290
+ // If the topmost group is the one we've already entered, drill
3291
+ // one level deeper — pick the next-down group on the chain.
3292
+ const target = this.pickDrillTarget(raw, top);
3293
+ if (target) {
3294
+ this._enteredGroup = target.id;
3295
+ this._selection = Selection.single(raw.id);
3296
+ if (this._selectedLinks.size > 0)
3297
+ this._selectedLinks = LinkSelection.EMPTY;
3298
+ this.notify();
3299
+ return true;
3300
+ }
3301
+ }
3302
+ }
3303
+ return false;
3304
+ }
3305
+ /**
3306
+ * Given the raw shape under the cursor and its topmost group
3307
+ * ancestor, pick which group to "enter" on a drill-down.
3308
+ *
3309
+ * - No group ancestor → null (drill-down doesn't apply).
3310
+ * - Top group not yet entered → enter top.
3311
+ * - Top group already entered → walk down the chain to find the
3312
+ * next group inward (one level deeper).
3313
+ */
3314
+ pickDrillTarget(raw, top) {
3315
+ return pickDrillTargetHelper(this._scene, raw, top, this._enteredGroup);
3316
+ }
3317
+ applyEmit(emit) {
3318
+ switch (emit.type) {
3319
+ case "SELECT_REPLACE":
3320
+ // Plain element click replaces the whole selection (elements + links).
3321
+ this._selection = Selection.single(emit.id);
3322
+ this._selectedLinks = LinkSelection.EMPTY;
3323
+ this.notify();
3324
+ return;
3325
+ case "SELECT_TOGGLE":
3326
+ // Shift/meta element click toggles the element; selected links stay.
3327
+ this._selection = Selection.toggle(this._selection, emit.id);
3328
+ this.notify();
3329
+ return;
3330
+ case "SELECT_CLEAR":
3331
+ this._selection = Selection.EMPTY;
3332
+ this._selectedLinks = LinkSelection.EMPTY;
3333
+ this.notify();
3334
+ return;
3335
+ case "SELECT_EDGE_REPLACE":
3336
+ // Plain link click replaces the whole selection with this one link.
3337
+ this._selectedLinks = LinkSelection.single(emit.id);
3338
+ this._selection = Selection.EMPTY;
3339
+ this.notify();
3340
+ return;
3341
+ case "SELECT_EDGE_TOGGLE":
3342
+ // Shift/meta link click toggles the link; selected elements stay.
3343
+ this._selectedLinks = LinkSelection.toggle(this._selectedLinks, emit.id);
3344
+ this.notify();
3345
+ return;
3346
+ case "SELECT_EDGE_CLEAR":
3347
+ if (this._selectedLinks.size > 0) {
3348
+ this._selectedLinks = LinkSelection.EMPTY;
3349
+ this.notify();
3350
+ }
3351
+ return;
3352
+ case "UPDATE_EDGE_ENDPOINT_PREVIEW":
3353
+ this.applyLinkEndpointMove(emit.linkId, emit.side, emit.toPoint);
3354
+ return;
3355
+ case "UPDATE_EDGE_ENDPOINT":
3356
+ this.applyLinkEndpointUpdate(emit);
3357
+ return;
3358
+ case "LASSO_PROGRESS":
3359
+ // Capture the pre-lasso selection on the first progress emit
3360
+ // of a gesture; subsequent emits use it as the additive base.
3361
+ this.lassoBaseSelection ??= this._selection;
3362
+ this.lassoBaseLinks ??= this._selectedLinks;
3363
+ this.lassoPreview = emit.bounds;
3364
+ this.applyLassoLiveSelection(emit.bounds, emit.mode);
3365
+ this.notify();
3366
+ return;
3367
+ case "LASSO_CLEAR":
3368
+ if (this.lassoPreview !== null ||
3369
+ this.lassoBaseSelection !== null ||
3370
+ this.lassoBaseLinks !== null) {
3371
+ this.lassoPreview = null;
3372
+ this.lassoBaseSelection = null;
3373
+ this.lassoBaseLinks = null;
3374
+ this.notify();
3375
+ }
3376
+ return;
3377
+ case "SELECT_BY_BOUNDS":
3378
+ // Final commit — uses the same logic as the live preview so
3379
+ // the visible selection matches what lands. Reset the base
3380
+ // snapshot so the next gesture re-captures it.
3381
+ this.lassoBaseSelection = null;
3382
+ this.lassoBaseLinks = null;
3383
+ this.applySelectByBounds(emit.bounds, emit.mode);
3384
+ return;
3385
+ case "MOVE_SHAPE":
3386
+ if (this.groupMoveOrigin) {
3387
+ this.applyGroupMove(emit.delta);
3388
+ }
3389
+ else {
3390
+ this.applyMove(emit.id, emit.delta, emit.originalBounds);
3391
+ }
3392
+ return;
3393
+ case "RESIZE_GROUP":
3394
+ this.applyGroupResize(emit.handle, emit.delta, emit.originalBounds);
3395
+ return;
3396
+ case "RESIZE_SHAPE":
3397
+ this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
3398
+ return;
3399
+ case "CREATE_SHAPE":
3400
+ this.applyCreate(emit.shapeType, emit.bounds);
3401
+ return;
3402
+ case "CREATE_EDGE":
3403
+ this.applyCreateLink(emit);
3404
+ return;
3405
+ case "DRAW_EDGE_PREVIEW":
3406
+ this.applyLinkPreview(emit.fromElement, emit.fromPoint, emit.toPoint);
3407
+ return;
3408
+ case "DRAW_EDGE_PREVIEW_CLEAR":
3409
+ if (this.edgePreview) {
3410
+ this.edgePreview = null;
3411
+ this.notify();
3412
+ }
3413
+ return;
3414
+ case "TEMPLATE_TAP":
3415
+ // Forward to subscribers via a custom listener path.
3416
+ for (const fn of this.templateTapListeners)
3417
+ fn(emit);
3418
+ return;
3419
+ case "TEMPLATE_DROP":
3420
+ for (const fn of this.templateDropListeners)
3421
+ fn(emit);
3422
+ return;
3423
+ case "MOVE_ANNOTATION":
3424
+ this.applyAnnotationMove(emit.id, emit.delta, emit.originalPosition);
3425
+ return;
3426
+ case "COMMIT_ANNOTATION_DRAG":
3427
+ this.finalizeOpenGestureTx();
3428
+ return;
3429
+ }
3430
+ }
3431
+ /**
3432
+ * Drag handler for annotation pins. Moves the pin to
3433
+ * `origin + delta`. Anchor semantics: for shape-anchored
3434
+ * annotations the `position` field is shape-local, so dragging
3435
+ * still updates the same field — the editor doesn't try to
3436
+ * reparent the anchor mid-drag; user wants the pin under the
3437
+ * cursor and that's exactly what `position + (delta in world)`
3438
+ * gives, regardless of which space the position is interpreted
3439
+ * in (`getAnnotationWorldPosition` already adds the shape's
3440
+ * world position when anchored).
3441
+ *
3442
+ * Wrapped in a single gestureTx so per-move updates collapse
3443
+ * into one undo step.
3444
+ */
3445
+ // Pure body in `./editor/applies/move.ts`.
3446
+ applyAnnotationMove(id, delta, origin) {
3447
+ const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
3448
+ if (!result)
3449
+ return;
3450
+ this._scene = result.scene;
3451
+ this.recordGesturePatch(result.patch);
3452
+ this.notify();
3453
+ }
3454
+ templateTapListeners = new Set();
3455
+ templateDropListeners = new Set();
3456
+ /**
3457
+ * Subscribe to template button taps. Returns an unsubscribe function.
3458
+ * Hosts use this to route template button clicks to their own actions.
3459
+ */
3460
+ onTemplateTap(fn) {
3461
+ this.templateTapListeners.add(fn);
3462
+ return () => this.templateTapListeners.delete(fn);
3463
+ }
3464
+ /**
3465
+ * Subscribe to drops onto template drop-zones. Returns an unsubscribe fn.
3466
+ * Hosts decide what to do with the drop (e.g. add a child shape, link
3467
+ * templates together).
3468
+ */
3469
+ onTemplateDrop(fn) {
3470
+ this.templateDropListeners.add(fn);
3471
+ return () => this.templateDropListeners.delete(fn);
3472
+ }
3473
+ /**
3474
+ * Dispatch a TEMPLATE_DROP emit programmatically. Hosts call this from their
3475
+ * own DOM `drop` listener after looking up which drop-zone (if any) is
3476
+ * under the pointer via `findDropZoneAt`.
3477
+ */
3478
+ dispatchTemplateDrop(emit) {
3479
+ this.applyEmit(emit);
3480
+ }
3481
+ // Pure body in `./editor/applies/move.ts`.
3482
+ applyMove(id, delta, originalBounds) {
3483
+ // Locked / layer-locked elements are selectable but don't move.
3484
+ const el = getElement(this._scene, id);
3485
+ if (el && !this.isElementManipulable(el))
3486
+ return;
3487
+ const d = this.snapActive() ? snapMoveDelta(originalBounds, delta, this.snapSpacing()) : delta;
3488
+ const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
3489
+ if (!patch)
3490
+ return;
3491
+ this._scene = apply(this._scene, patch);
3492
+ this.recordGesturePatch(patch);
3493
+ this.notify();
3494
+ }
3495
+ applyGroupMove(delta) {
3496
+ if (!this.groupMoveOrigin)
3497
+ return;
3498
+ const d = this.snapActive()
3499
+ ? snapGroupDelta(this.groupMoveOrigin, delta, this.snapSpacing())
3500
+ : delta;
3501
+ const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
3502
+ for (const patch of patches) {
3503
+ this._scene = apply(this._scene, patch);
3504
+ this.recordGesturePatch(patch);
3505
+ }
3506
+ // Selected links + connectors bound on both ends to moved elements
3507
+ // translate with the drag (standard parity) — shifted by the
3508
+ // same delta from the press-time snapshot.
3509
+ if (this.groupLinkMoveOrigin) {
3510
+ const linkPatches = computeMovingLinkPatches(this._scene, this.groupLinkMoveOrigin, d);
3511
+ for (const patch of linkPatches) {
3512
+ this._scene = apply(this._scene, patch);
3513
+ this.recordGesturePatch(patch);
3514
+ }
3515
+ }
3516
+ this.notify();
3517
+ }
3518
+ // Body moved to `./editor/viewport-helpers.ts`.
3519
+ computeViewportWorld() {
3520
+ return computeViewportWorldPure(this._scene);
3521
+ }
3522
+ /**
3523
+ * Identity-diff the current scene against the last rendered one and
3524
+ * return the union AABB of every shape/edge that changed reference
3525
+ * (added, removed, or replaced). Returns `null` to force a full
3526
+ * clear when something that affects the entire surface changes —
3527
+ * viewport pan/zoom/resize, layer visibility, or the first frame.
3528
+ *
3529
+ * Empty union (returned as a zero-area bbox far off-screen) means
3530
+ * nothing changed; renderScene will cull every shape via its
3531
+ * `dirtyWorld` filter — effectively a no-op main pass.
3532
+ */
3533
+ computeDirtyWorld() {
3534
+ const prev = this.lastRenderedScene;
3535
+ const next = this._scene;
3536
+ if (!prev)
3537
+ return null;
3538
+ // Skip dirty-rect optimization until the canvas has been sized at
3539
+ // least once — the first paint to a 0×0 viewport doesn't actually
3540
+ // hit pixels, so we have to force a full repaint as soon as the
3541
+ // host's ResizeObserver fires (even when the diff finds zero
3542
+ // changed shapes, e.g. when only viewport.size changed but the
3543
+ // viewport ref happened to equal — which can't happen, but defence
3544
+ // in depth).
3545
+ if (prev.viewport.size.width <= 0 ||
3546
+ prev.viewport.size.height <= 0 ||
3547
+ next.viewport.size.width <= 0 ||
3548
+ next.viewport.size.height <= 0) {
3549
+ return null;
3550
+ }
3551
+ // Active gesture (drag / resize / brush / placement) → full
3552
+ // repaint. Dirty-rect optimisation skipped on purpose:
3553
+ //
3554
+ // • transitive-overlap expansion catches the common case but
3555
+ // misses corner cases (edges attached to moving shapes,
3556
+ // group descendants that aren't all siblings, hovered
3557
+ // ports that decorate a different layer);
3558
+ // • during a drag we already repaint the largest dirty area
3559
+ // in the scene (the moving shape's swept bbox), so the
3560
+ // dirty optimisation buys almost no perf — the only thing
3561
+ // it does is occasionally drop a frame for a sibling that
3562
+ // should have been redrawn underneath / above the mover;
3563
+ // • once the gesture commits, the next render falls back to
3564
+ // normal dirty-rect behaviour again.
3565
+ //
3566
+ // Net: trade ~1 frame's worth of work during the drag for
3567
+ // guaranteed correct z-order.
3568
+ if (this.gestureTx !== null ||
3569
+ this.dragElementId !== null ||
3570
+ this.drawingPreview !== null ||
3571
+ this.edgePreview !== null ||
3572
+ this.brushStroke !== null ||
3573
+ this.lassoPreview !== null) {
3574
+ return null;
3575
+ }
3576
+ // Anything that affects the global render — viewport (pan / zoom /
3577
+ // size) or layer ordering / visibility — forces a full clear.
3578
+ if (prev.viewport !== next.viewport)
3579
+ return null;
3580
+ if (prev.layers !== next.layers)
3581
+ return null;
3582
+ // Isolation transition (enter / exit a group) re-dims a wide swath
3583
+ // of shapes without touching the scene reference, so force a full
3584
+ // repaint when the entered-group identity changes between frames.
3585
+ if (this.lastRenderedEnteredGroup !== this._enteredGroup)
3586
+ return null;
3587
+ // Scene ref unchanged → nothing changed on main canvas → skip the
3588
+ // whole pass via an empty off-screen rect that the dirty filter
3589
+ // culls every shape against.
3590
+ if (prev === next) {
3591
+ return { x: -1e9, y: -1e9, width: 0, height: 0 };
3592
+ }
3593
+ let acc = null;
3594
+ const add = (b) => {
3595
+ acc = acc ? B.union(acc, b) : b;
3596
+ };
3597
+ // Track shapes that changed (added / removed / mutated). Links
3598
+ // attached to any of these have stale rendered paths even when
3599
+ // the edge object itself is reference-equal — the path resolves
3600
+ // through the shape's new position, but the old path stays on
3601
+ // screen as a "ghost" trail unless we explicitly invalidate it.
3602
+ const changedElementIds = new Set();
3603
+ for (const [id, shape] of next.elements) {
3604
+ const old = prev.elements.get(id);
3605
+ if (old === shape)
3606
+ continue;
3607
+ changedElementIds.add(id);
3608
+ // Render bounds (not geometric) so overpaint — a frame's header
3609
+ // strip, confetti particles — is cleared too, no ghost trail.
3610
+ const afterBounds = getElementRenderBounds(shape);
3611
+ const beforeBounds = old ? getElementRenderBounds(old) : null;
3612
+ add(afterBounds);
3613
+ if (beforeBounds)
3614
+ add(beforeBounds);
3615
+ // Stash for the tile-cache path — covers add + move via
3616
+ // before/after pair; pure mutation re-uses the single
3617
+ // afterBounds rect.
3618
+ if (this.tileComposeFn !== null) {
3619
+ this.tileDirtyElements.set(id, { before: beforeBounds, after: afterBounds });
3620
+ }
3621
+ }
3622
+ for (const [id, shape] of prev.elements) {
3623
+ if (!next.elements.has(id)) {
3624
+ changedElementIds.add(id);
3625
+ // Render bounds so a removed frame/confetti clears its overpaint.
3626
+ const beforeBounds = getElementRenderBounds(shape);
3627
+ add(beforeBounds);
3628
+ if (this.tileComposeFn !== null) {
3629
+ this.tileDirtyElements.set(id, { before: beforeBounds, after: null });
3630
+ }
3631
+ }
3632
+ }
3633
+ const linkTouchesChangedElement = (edge) => {
3634
+ for (const ep of [edge.from, edge.to]) {
3635
+ if (ep.kind !== "point") {
3636
+ if (changedElementIds.has(ep.elementId))
3637
+ return true;
3638
+ }
3639
+ }
3640
+ return false;
3641
+ };
3642
+ for (const [id, edge] of next.links) {
3643
+ const old = prev.links.get(id);
3644
+ // Refresh edge dirty-rect when: edge object changed, OR an
3645
+ // endpoint references a shape that moved this frame (path is
3646
+ // re-resolved every render but the old screen pixels persist).
3647
+ if (old === edge && !linkTouchesChangedElement(edge))
3648
+ continue;
3649
+ const b = computeLinkWorldBounds(next, edge);
3650
+ if (b)
3651
+ add(b);
3652
+ const oldLink = old ?? edge; // prev scene resolves with prev shapes for ghost-clear
3653
+ const ob = computeLinkWorldBounds(prev, oldLink);
3654
+ if (ob)
3655
+ add(ob);
3656
+ }
3657
+ for (const [id, edge] of prev.links) {
3658
+ if (!next.links.has(id)) {
3659
+ const b = computeLinkWorldBounds(prev, edge);
3660
+ if (b)
3661
+ add(b);
3662
+ }
3663
+ }
3664
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `acc` is mutated via the `add` closure; TS flow analysis can't see it and narrows to null
3665
+ if (acc === null)
3666
+ return { x: -1e9, y: -1e9, width: 0, height: 0 };
3667
+ // Transitive expansion: any shape whose bounds intersect the
3668
+ // current dirty rect must be repainted, AND its bounds added
3669
+ // to the dirty rect so any shape ABOVE it that overlaps gets
3670
+ // included too. Repeat until the set stabilises.
3671
+ //
3672
+ // Without this, dragging A through a B/C stack produces
3673
+ // visual jitter: B intersects the dirty rect and gets
3674
+ // repainted, but C — sitting above B and partially overlapping
3675
+ // it — doesn't intersect the original dirty, so B re-emerges
3676
+ // on top of where C should still be drawn. Z-order is correct
3677
+ // in `getElementsInLayer`; the issue is missed shapes, not
3678
+ // wrong order.
3679
+ const visited = new Set();
3680
+ let expanded = acc;
3681
+ let grew = true;
3682
+ while (grew) {
3683
+ grew = false;
3684
+ for (const shape of next.elements.values()) {
3685
+ if (visited.has(shape.id))
3686
+ continue;
3687
+ const bb = getElementWorldBounds(shape);
3688
+ if (!B.intersects(bb, expanded))
3689
+ continue;
3690
+ visited.add(shape.id);
3691
+ const merged = B.union(expanded, bb);
3692
+ if (merged.x !== expanded.x ||
3693
+ merged.y !== expanded.y ||
3694
+ merged.width !== expanded.width ||
3695
+ merged.height !== expanded.height) {
3696
+ expanded = merged;
3697
+ grew = true;
3698
+ }
3699
+ }
3700
+ }
3701
+ // Inflate by a couple pixels to cover anti-aliased stroke fuzz
3702
+ // around the geometry edges.
3703
+ return B.expand(expanded, 4);
3704
+ }
3705
+ // Bodies moved to `./editor/viewport-helpers.ts`.
3706
+ combinedSelectionBounds() {
3707
+ let acc = combinedSelectionBoundsPure(this._scene, this._selection);
3708
+ // Selected links join the selection box (standard parity) — union in
3709
+ // each link's drawn-path AABB so the rectangle frames connectors too.
3710
+ for (const id of this._selectedLinks) {
3711
+ const edge = getLink(this._scene, id);
3712
+ if (!edge)
3713
+ continue;
3714
+ const path = getLinkPath(this._scene, edge);
3715
+ if (!path || path.length === 0)
3716
+ continue;
3717
+ let minX = Infinity;
3718
+ let minY = Infinity;
3719
+ let maxX = -Infinity;
3720
+ let maxY = -Infinity;
3721
+ for (const p of path) {
3722
+ if (p.x < minX)
3723
+ minX = p.x;
3724
+ if (p.y < minY)
3725
+ minY = p.y;
3726
+ if (p.x > maxX)
3727
+ maxX = p.x;
3728
+ if (p.y > maxY)
3729
+ maxY = p.y;
3730
+ }
3731
+ const b = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
3732
+ acc = acc ? B.union(acc, b) : b;
3733
+ }
3734
+ return acc;
3735
+ }
3736
+ groupChildrenUnion(groupId) {
3737
+ return groupChildrenUnionPure(this._scene, groupId);
3738
+ }
3739
+ /**
3740
+ * True when the current selection should be treated as aspect-
3741
+ * locked for group-handle resize. Currently: a single `group`-typed
3742
+ * shape selected. Multi-selection of free shapes keeps the default
3743
+ * 8-handle / free-aspect behaviour (matches user expectation:
3744
+ * grouping is the explicit "lock the ratio" gesture).
3745
+ */
3746
+ selectionIsAspectLocked() {
3747
+ if (this._selection.size === 0)
3748
+ return false;
3749
+ if (this._selection.size === 1) {
3750
+ const [only] = [...this._selection];
3751
+ if (!only)
3752
+ return false;
3753
+ // A single group (grouping IS the explicit "lock ratio" gesture)
3754
+ // or a single image (images may only be scaled, never distorted).
3755
+ const type = getElement(this._scene, only)?.type;
3756
+ return type === "group" || type === "image";
3757
+ }
3758
+ // Multi-selection: lock when every selected shape is an image — they
3759
+ // must never be stretched out of ratio, only scaled together.
3760
+ for (const id of this._selection) {
3761
+ if (getElement(this._scene, id)?.type !== "image")
3762
+ return false;
3763
+ }
3764
+ return true;
3765
+ }
3766
+ // Pure body in `./editor/applies/resize.ts`.
3767
+ applyGroupResize(handle, delta, originalBounds) {
3768
+ if (!this.groupResizeOrigin)
3769
+ return;
3770
+ const d = this.snapActive()
3771
+ ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
3772
+ : delta;
3773
+ const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds, this.selectionIsAspectLocked());
3774
+ this._scene = result.scene;
3775
+ for (const patch of result.patches)
3776
+ this.recordGesturePatch(patch);
3777
+ this.notify();
3778
+ }
3779
+ applyResize(id, handle, delta, originalBounds) {
3780
+ const shape = getElement(this._scene, id);
3781
+ const d = this.snapActive()
3782
+ ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
3783
+ : delta;
3784
+ // Text: aspect-locked font scaling. Snapshot the pristine shape on
3785
+ // the gesture's first tick so the scale base never compounds.
3786
+ if (shape?.type === "text") {
3787
+ if (this._resizeOriginElement?.id !== id) {
3788
+ this._resizeOriginElement = shape;
3789
+ }
3790
+ const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds);
3791
+ if (!result)
3792
+ return;
3793
+ this._scene = result.scene;
3794
+ this.recordGesturePatch(result.patch);
3795
+ this.notify();
3796
+ return;
3797
+ }
3798
+ const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h));
3799
+ if (!result)
3800
+ return;
3801
+ this._scene = result.scene;
3802
+ this.recordGesturePatch(result.patch);
3803
+ this.notify();
3804
+ }
3805
+ // Pure body in `./editor/applies/create.ts`.
3806
+ applyCreate(kind, bounds) {
3807
+ const id = newElementId(++this.nextId);
3808
+ const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
3809
+ const result = computeCreateElement(this._scene, kind, b, id, this._activeLayerId, () => this.nextFrameName());
3810
+ this._scene = result.scene;
3811
+ this._selection = Selection.single(id);
3812
+ // CREATE is a single-shot operation, not part of a multi-tick gesture.
3813
+ this._history.push(result.patch);
3814
+ // Frame-specific: scoop up every shape whose centre lies inside
3815
+ // the new frame's bounds and tag them with `frameId`.
3816
+ if (kind === "frame") {
3817
+ this.assignFrameMembers(id, b);
3818
+ }
3819
+ this.maybeRevertModeAfterCreate();
3820
+ this.notify();
3821
+ }
3822
+ /** Generate the next "Frame N" name based on existing frames. */
3823
+ nextFrameName() {
3824
+ return nextFrameNameHelper(this._scene);
3825
+ }
3826
+ /**
3827
+ * Assign frameId to every shape (except the frame itself) whose
3828
+ * centre falls inside the frame's world bounds. Runs as a single
3829
+ * undo step in the same gesture transaction as the create.
3830
+ */
3831
+ assignFrameMembers(frameId, frameBounds) {
3832
+ this._scene = assignFrameMembersHelper(this._scene, this._history, frameId, frameBounds);
3833
+ }
3834
+ /**
3835
+ * Re-evaluate frame membership at the end of a move / resize gesture —
3836
+ * elements dropped inside a frame join it, those dragged out are
3837
+ * released (standard "membership on drop"). Runs inside the gesture
3838
+ * transaction (called from pointer-up before `commitGesture`) so the
3839
+ * frameId changes undo together with the drag. No-op when nothing
3840
+ * changed.
3841
+ */
3842
+ reconcileFrameMembership() {
3843
+ this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
3844
+ }
3845
+ // Pure body in `./editor/applies/create.ts`. Endpoint snapping
3846
+ // stays here because it needs the snap engine.
3847
+ applyCreateLink(emit) {
3848
+ const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
3849
+ const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
3850
+ const id = newLinkId(++this.nextId);
3851
+ const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId);
3852
+ this._scene = result.scene;
3853
+ this._history.push(result.patch);
3854
+ this.edgePreview = null;
3855
+ this.maybeRevertModeAfterCreate();
3856
+ // Dropped on empty canvas (free `point` end) → offer a shape-picker at
3857
+ // the drop point (standard). The free-ended link stays; picking re-points
3858
+ // it, dismissing keeps it. Only the `to` end is user-dragged here.
3859
+ if (to.kind === "point") {
3860
+ this.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
3861
+ }
3862
+ this.notify();
3863
+ }
3864
+ /** Pending shape-picker after a link was dropped on empty canvas. */
3865
+ get linkDropMenu() {
3866
+ return this.pendingLinkDropMenu;
3867
+ }
3868
+ /**
3869
+ * Resolve a pending link-drop shape-picker by creating an element from
3870
+ * `factory` centred at the drop point and re-pointing the dropped link
3871
+ * end to float against it. Element + re-point land in one undo step; the
3872
+ * new element becomes the selection. No-op when no menu is pending.
3873
+ */
3874
+ placeShapeAtLinkDrop(factory) {
3875
+ const pending = this.pendingLinkDropMenu;
3876
+ if (!pending)
3877
+ return;
3878
+ const link = getLink(this._scene, pending.linkId);
3879
+ if (!link) {
3880
+ this.pendingLinkDropMenu = null;
3881
+ this.notify();
3882
+ return;
3883
+ }
3884
+ const newId = newElementId(++this.nextId);
3885
+ const order = orderForTop([...this._scene.elements.values()]
3886
+ .filter((sh) => sh.layerId === this._activeLayerId)
3887
+ .map((sh) => sh.order));
3888
+ const built = factory({
3889
+ id: newId,
3890
+ layerId: this._activeLayerId,
3891
+ position: pending.world,
3892
+ order,
3893
+ });
3894
+ // Centre the element on the drop point regardless of how the factory
3895
+ // anchored it at `position`.
3896
+ const wb = getElementWorldBounds(built);
3897
+ const shape = {
3898
+ ...built,
3899
+ position: {
3900
+ x: built.position.x + (pending.world.x - (wb.x + wb.width / 2)),
3901
+ y: built.position.y + (pending.world.y - (wb.y + wb.height / 2)),
3902
+ },
3903
+ };
3904
+ const tx = this._history.transaction();
3905
+ const added = addElement(this._scene, shape);
3906
+ this._scene = added.scene;
3907
+ tx.add(added.patch);
3908
+ const upd = updateLink(this._scene, pending.linkId, (e) => ({
3909
+ ...e,
3910
+ [pending.side]: { kind: "floating", elementId: newId },
3911
+ }));
3912
+ this._scene = upd.scene;
3913
+ tx.add(upd.patch);
3914
+ tx.commit();
3915
+ this.pendingLinkDropMenu = null;
3916
+ this._selection = Selection.single(newId);
3917
+ this._selectedLinks = LinkSelection.EMPTY;
3918
+ this.notify();
3919
+ }
3920
+ /** Dismiss the link-drop shape-picker, leaving the free-ended link. */
3921
+ dismissLinkDropMenu() {
3922
+ if (!this.pendingLinkDropMenu)
3923
+ return;
3924
+ this.pendingLinkDropMenu = null;
3925
+ this.notify();
3926
+ }
3927
+ /**
3928
+ * standard "click a link-start dot" gesture: spawn a new element in that
3929
+ * dot's outward direction and link the source to it. The clone copies
3930
+ * the source's type / style / size but NOT its text (a fresh blank of
3931
+ * the same kind). Direction is source → new; the new element becomes the
3932
+ * selection. Element + link land in one undo step.
3933
+ */
3934
+ createLinkedElementFromAnchor(fromElement, anchorName) {
3935
+ const src = getElement(this._scene, fromElement);
3936
+ if (!src)
3937
+ return;
3938
+ const anchor = { kind: "named", name: anchorName };
3939
+ const normal = getAnchorOutwardNormal(src, anchor);
3940
+ const bounds = getElementWorldBounds(src);
3941
+ const srcCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
3942
+ // Same-size clone → centre-to-centre distance = the source's extent
3943
+ // along the normal + the gap, leaving exactly
3944
+ // ANCHOR_CLICK_NEW_ELEMENT_GAP between the facing edges. `extentAlong`
3945
+ // resolves to width for a horizontal normal, height for a vertical one
3946
+ // (link-start dots are the four edge midpoints).
3947
+ const extentAlong = Math.abs(normal.x) * bounds.width + Math.abs(normal.y) * bounds.height;
3948
+ const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
3949
+ const delta = { x: normal.x * dist, y: normal.y * dist };
3950
+ const newId = newElementId(++this.nextId);
3951
+ const order = orderForTop([...this._scene.elements.values()]
3952
+ .filter((sh) => sh.layerId === src.layerId)
3953
+ .map((sh) => sh.order));
3954
+ let clone = {
3955
+ ...src,
3956
+ id: newId,
3957
+ position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
3958
+ order,
3959
+ };
3960
+ // Blank user text — the new element is a fresh same-kind shape, not a
3961
+ // content copy (standard). Only `text` (TextElement) and `name`
3962
+ // (FrameElement) carry user-entered text. Cast through `Element` because
3963
+ // `exactOptionalPropertyTypes` rejects the bare object literal against
3964
+ // the union (TS2375), even though the narrowed branch is sound.
3965
+ if (clone.type === "text")
3966
+ clone = { ...clone, text: "" };
3967
+ else if (clone.type === "frame")
3968
+ clone = { ...clone, name: "" };
3969
+ const tx = this._history.transaction();
3970
+ const added = addElement(this._scene, clone);
3971
+ this._scene = added.scene;
3972
+ tx.add(added.patch);
3973
+ const linkId = newLinkId(++this.nextId);
3974
+ const placed = req(getElement(this._scene, newId));
3975
+ const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
3976
+ const linkResult = computeCreateLink(this._scene, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: newId, anchor: toRef }, linkId, this._activeLayerId);
3977
+ this._scene = linkResult.scene;
3978
+ tx.add(linkResult.patch);
3979
+ tx.commit();
3980
+ this._selection = Selection.single(newId);
3981
+ if (this._selectedLinks.size > 0)
3982
+ this._selectedLinks = LinkSelection.EMPTY;
3983
+ this.notify();
3984
+ }
3985
+ /**
3986
+ * Ghost geometry for what clicking a start dot would create (standard hover
3987
+ * preview): the would-be new element's world bounds + the connector path
3988
+ * from the dot to it. Pure — no mutation. Mirrors the placement in
3989
+ * `createLinkedElementFromAnchor`.
3990
+ */
3991
+ previewClickCreate(fromElement, anchorName) {
3992
+ const src = getElement(this._scene, fromElement);
3993
+ if (!src)
3994
+ return null;
3995
+ const anchor = { kind: "named", name: anchorName };
3996
+ const normal = getAnchorOutwardNormal(src, anchor);
3997
+ const b = getElementWorldBounds(src);
3998
+ const extentAlong = Math.abs(normal.x) * b.width + Math.abs(normal.y) * b.height;
3999
+ const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
4000
+ const delta = { x: normal.x * dist, y: normal.y * dist };
4001
+ const bounds = { x: b.x + delta.x, y: b.y + delta.y, width: b.width, height: b.height };
4002
+ const fromWorld = getAnchorWorld(src, anchor);
4003
+ // Facing edge of the ghost (toward the source) = its centre pulled back
4004
+ // along the normal by half its extent.
4005
+ const ghostCx = bounds.x + bounds.width / 2;
4006
+ const ghostCy = bounds.y + bounds.height / 2;
4007
+ const nearEdge = {
4008
+ x: ghostCx - normal.x * (extentAlong / 2),
4009
+ y: ghostCy - normal.y * (extentAlong / 2),
4010
+ };
4011
+ // The would-be element itself — a same-kind clone of the source shifted
4012
+ // to the ghost bounds, with blank user text (mirrors
4013
+ // `createLinkedElementFromAnchor`). The overlay renders THIS through the
4014
+ // real renderer so the ghost looks like the actual shape (an ellipse
4015
+ // ghosts as an ellipse), not a bounding rect. Throwaway id — never enters
4016
+ // the real scene.
4017
+ let element = {
4018
+ ...src,
4019
+ id: PREVIEW_GHOST_ELEMENT_ID,
4020
+ position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
4021
+ };
4022
+ if (element.type === "text")
4023
+ element = { ...element, text: "" };
4024
+ else if (element.type === "frame")
4025
+ element = { ...element, name: "" };
4026
+ // Build a throwaway scene holding the ghost element + the would-be link so
4027
+ // the connector can be drawn through the REAL link renderer (same routing,
4028
+ // arrowhead and style it'll have once created) — faded — instead of a
4029
+ // dashed preview line. Mirrors the link build in
4030
+ // `createLinkedElementFromAnchor` exactly. The `path` field stays for
4031
+ // callers that just want the straight from→to segment.
4032
+ const srcCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
4033
+ const withGhost = addElement(this._scene, element).scene;
4034
+ const placed = req(getElement(withGhost, PREVIEW_GHOST_ELEMENT_ID));
4035
+ const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
4036
+ const linkResult = computeCreateLink(withGhost, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: PREVIEW_GHOST_ELEMENT_ID, anchor: toRef }, PREVIEW_GHOST_LINK_ID, this._activeLayerId);
4037
+ let ghostScene = linkResult.scene;
4038
+ const edge = req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID));
4039
+ if ((edge.routing ?? "straight") === "orthogonal") {
4040
+ const routedPoints = routeElbowLink(ghostScene, edge);
4041
+ ghostScene = updateLink(ghostScene, PREVIEW_GHOST_LINK_ID, (e) => ({
4042
+ ...e,
4043
+ routedPoints,
4044
+ })).scene;
4045
+ }
4046
+ // Render only the ghost link (the shapes stay for endpoint resolution).
4047
+ ghostScene = {
4048
+ ...ghostScene,
4049
+ links: new Map([[PREVIEW_GHOST_LINK_ID, req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID))]]),
4050
+ };
4051
+ return {
4052
+ bounds,
4053
+ path: [fromWorld, nearEdge],
4054
+ element,
4055
+ ghostScene,
4056
+ ghostLinkId: PREVIEW_GHOST_LINK_ID,
4057
+ };
4058
+ }
4059
+ /**
4060
+ * Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
4061
+ * scene's snap engine for the probe point, prefers anchor snap when
4062
+ * close enough, falls back to outline snap (so the user can attach
4063
+ * "anywhere on the right edge"), then `point` for the free-floating
4064
+ * case.
4065
+ *
4066
+ * `pressTargetElement` is the shape the gesture originated from or
4067
+ * landed on (used as a strong hint — we don't snap onto unrelated
4068
+ * shapes when the user clearly aimed for this one).
4069
+ */
4070
+ snapLinkEndpoint(pressTargetElement, worldPoint) {
4071
+ const result = this.snapEngine.snap({
4072
+ scene: this._scene,
4073
+ probe: worldPoint,
4074
+ threshold: this.snapThreshold,
4075
+ gesture: "draw-edge",
4076
+ });
4077
+ // Attach contract: dropping on a port dot → *fixed* anchor; dropping
4078
+ // near an EDGE (not a dot) → *fixed* outline point (a ratio along the
4079
+ // perimeter — survives move/resize); dropping on the body interior (no
4080
+ // snap to a dot or edge) → *floating* against the whole shape, so the
4081
+ // connection re-aims at the partner as either shape moves; dropping on
4082
+ // empty canvas → a free point.
4083
+ //
4084
+ // Pick a candidate ON the pressed shape if there is one, otherwise the
4085
+ // nearest overall. The "nearest overall" branch matters because the
4086
+ // attach DOTS are drawn OUTSIDE the body — a release on a dot finds no
4087
+ // element under it (hit-test = empty), so `pressTargetElement` is null,
4088
+ // yet the snap engine still reports the dot's anchor within threshold.
4089
+ // Binding it makes the endpoint enter PERPENDICULAR to that edge instead
4090
+ // of staying a free point that aims at the partner (jumping between the
4091
+ // four sides).
4092
+ const pick = (kind) => {
4093
+ if (pressTargetElement !== null) {
4094
+ const onTarget = result.all.find((c) => c.kind === kind && c.metadata?.elementId === pressTargetElement);
4095
+ if (onTarget)
4096
+ return onTarget;
4097
+ }
4098
+ return result.all.find((c) => c.kind === kind);
4099
+ };
4100
+ const boundFrom = (cand, want) => {
4101
+ if (!cand)
4102
+ return null;
4103
+ const elId = cand.metadata?.elementId;
4104
+ if (elId === undefined)
4105
+ return null;
4106
+ const shp = getElement(this._scene, elId);
4107
+ if (!shp)
4108
+ return null;
4109
+ const ep = endpointFromSnap(elId, cand, shp);
4110
+ return ep.kind === want ? ep : null;
4111
+ };
4112
+ const anchorEp = boundFrom(pick("anchor"), "anchor");
4113
+ if (anchorEp)
4114
+ return anchorEp;
4115
+ const outlineEp = boundFrom(pick("outline"), "outline");
4116
+ if (outlineEp)
4117
+ return outlineEp;
4118
+ // No dot/edge snap. Over a shape body → floating; else a free point.
4119
+ if (pressTargetElement !== null && getElement(this._scene, pressTargetElement)) {
4120
+ return { kind: "floating", elementId: pressTargetElement };
4121
+ }
4122
+ return { kind: "point", position: worldPoint };
4123
+ }
4124
+ // Pure body in `./editor/applies/selection.ts`. The wrappers
4125
+ // here own the side effects (`_selectedLink` clearing, notify).
4126
+ applySelectByBounds(bounds, mode) {
4127
+ const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
4128
+ const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
4129
+ const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
4130
+ this._selectedLinks = nextLinks;
4131
+ if (Selection.equals(next, this._selection) && !linksChanged) {
4132
+ this.notify();
4133
+ return;
4134
+ }
4135
+ this._selection = next;
4136
+ this.notify();
4137
+ }
4138
+ applyLassoLiveSelection(bounds, mode) {
4139
+ const base = this.lassoBaseSelection ?? Selection.EMPTY;
4140
+ const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
4141
+ const linkBase = this.lassoBaseLinks ?? LinkSelection.EMPTY;
4142
+ const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
4143
+ const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
4144
+ if (Selection.equals(next, this._selection) && !linksChanged)
4145
+ return;
4146
+ this._selectedLinks = nextLinks;
4147
+ this._selection = next;
4148
+ }
4149
+ // Pure body in `./editor/applies/edge.ts`. The wrapper here
4150
+ // owns the side effects (history push, drag-state clearing,
4151
+ // notify).
4152
+ /**
4153
+ * Live endpoint-rebind move: re-point the dragged end to the cursor in the
4154
+ * scene (a free `point` endpoint), recorded in the gesture transaction so the
4155
+ * WHOLE link redraws under the cursor with full fidelity — real style,
4156
+ * arrowhead, curved bow, and (via `rerouteElbows` in `render`) a live elbow
4157
+ * re-route. One undo step on commit; Escape cancels the transaction and the
4158
+ * link snaps back to where it was. The handle dot follows via `linkEndpointDrag`.
4159
+ */
4160
+ applyLinkEndpointMove(linkId, side, toPoint) {
4161
+ const edge = getLink(this._scene, linkId);
4162
+ if (!edge)
4163
+ return;
4164
+ // A real drag breaks the handle double-click chain (mirrors waypoint /
4165
+ // segment drags) so a quick click after dropping isn't read as a delete.
4166
+ this.lastHandleClickAt = 0;
4167
+ // Resolve the attach target under the cursor and snap the endpoint to it
4168
+ // with the SAME logic the drop uses, so the link attaches LIVE exactly as it
4169
+ // will commit — lands on the dot (fixed), floats on the body, or stays a
4170
+ // free point over empty space.
4171
+ const target = this.linkAttachTargetAt(toPoint);
4172
+ const targetId = target?.kind === "element" ? target.id : null;
4173
+ const ep = this.snapLinkEndpoint(targetId, toPoint);
4174
+ const r = updateLink(this._scene, linkId, (e) => side === "from" ? { ...e, from: ep } : { ...e, to: ep });
4175
+ this._scene = r.scene;
4176
+ this.recordGesturePatch(r.patch);
4177
+ this.linkEndpointDrag = { linkId, side, toPoint };
4178
+ // Attach-point highlight — the SAME feedback as drawing a new link
4179
+ // (candidate dots + float-element halo), driven by `hoveredLinkTarget`.
4180
+ this.updateHoveredLinkTarget(toPoint);
4181
+ this.notify();
4182
+ }
4183
+ applyLinkEndpointUpdate(emit) {
4184
+ // A move opened a gesture transaction (live re-point per tick). The final
4185
+ // snapped endpoint goes into the SAME transaction so the net history step is
4186
+ // original → final (one undo). A pure click (no move, no tx) that resolves
4187
+ // to a no-op change must not leave a junk undo entry.
4188
+ const moved = this.gestureTx !== null;
4189
+ const result = computeLinkEndpointUpdate(this._scene, emit, (toElement, toPoint) => this.snapLinkEndpoint(toElement, toPoint));
4190
+ if (result === null) {
4191
+ this.cancelGesture();
4192
+ this.linkEndpointDrag = null;
4193
+ this.hoveredLinkTarget = null;
4194
+ this.notify();
4195
+ return;
4196
+ }
4197
+ if (!moved && isNoop(result.patch)) {
4198
+ this.linkEndpointDrag = null;
4199
+ this.hoveredLinkTarget = null;
4200
+ this.notify();
4201
+ return;
4202
+ }
4203
+ this._scene = result.scene;
4204
+ this.recordGesturePatch(result.patch);
4205
+ this.commitGesture();
4206
+ this.linkEndpointDrag = null;
4207
+ this.hoveredLinkTarget = null;
4208
+ }
4209
+ /** True while a waypoint of the selected link is being dragged. */
4210
+ get isDraggingWaypoint() {
4211
+ return this.linkWaypointDrag !== null;
4212
+ }
4213
+ /**
4214
+ * Begin a host-managed waypoint drag. `insert` splices a new waypoint at
4215
+ * `index` on the first move (segment-midpoint "add" handle); otherwise an
4216
+ * existing waypoint at `index` is moved. Live-mutated through the gesture
4217
+ * transaction so the whole drag is one undo step.
4218
+ */
4219
+ beginWaypointDrag(linkId, index, insert) {
4220
+ if (!getLink(this._scene, linkId))
4221
+ return;
4222
+ this.linkWaypointDrag = { linkId, index, pendingInsert: insert };
4223
+ }
4224
+ /** Live update of the dragged waypoint to `world`. */
4225
+ updateWaypointDrag(world) {
4226
+ const drag = this.linkWaypointDrag;
4227
+ if (!drag)
4228
+ return;
4229
+ // A real drag breaks the handle double-click chain (see updateSegmentDrag).
4230
+ this.lastHandleClickAt = 0;
4231
+ const edge = getLink(this._scene, drag.linkId);
4232
+ if (!edge)
4233
+ return;
4234
+ const wps = [...(edge.waypoints ?? [])];
4235
+ if (drag.pendingInsert) {
4236
+ wps.splice(drag.index, 0, world);
4237
+ drag.pendingInsert = false;
4238
+ }
4239
+ else {
4240
+ if (drag.index < 0 || drag.index >= wps.length)
4241
+ return;
4242
+ wps[drag.index] = world;
4243
+ }
4244
+ const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
4245
+ this._scene = r.scene;
4246
+ this.recordGesturePatch(r.patch);
4247
+ this.notify();
4248
+ }
4249
+ /**
4250
+ * Finish the waypoint drag. If the dragged waypoint landed within
4251
+ * `WAYPOINT_COLLAPSE_RADIUS` of an adjacent path point, it is removed
4252
+ * (drag-onto-the-line to delete). A no-move insert adds nothing.
4253
+ */
4254
+ endWaypointDrag() {
4255
+ const drag = this.linkWaypointDrag;
4256
+ this.linkWaypointDrag = null;
4257
+ if (!drag)
4258
+ return;
4259
+ if (drag.pendingInsert) {
4260
+ // Never moved → it was a click on a midpoint; nothing inserted.
4261
+ this.commitGesture();
4262
+ return;
4263
+ }
4264
+ const edge = getLink(this._scene, drag.linkId);
4265
+ if (edge?.waypoints && drag.index >= 0 && drag.index < edge.waypoints.length) {
4266
+ const path = getLinkPath(this._scene, edge);
4267
+ const wp = req(edge.waypoints[drag.index]);
4268
+ // Neighbours in the [from, ...waypoints, to] chain: path[index] and
4269
+ // path[index + 2] (path[0] = from, so waypoint i sits at path[i + 1]).
4270
+ // Dropping the waypoint back onto the straight segment between its
4271
+ // neighbours removes the bend ("drag onto the line to delete").
4272
+ const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
4273
+ const a = path?.[drag.index];
4274
+ const b = path?.[drag.index + 2];
4275
+ if (a && b && distanceToSegmentPt(wp, a, b) <= collapse) {
4276
+ const wps = edge.waypoints.filter((_, i) => i !== drag.index);
4277
+ const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
4278
+ this._scene = r.scene;
4279
+ this.recordGesturePatch(r.patch);
4280
+ }
4281
+ }
4282
+ this.commitGesture();
4283
+ }
4284
+ /** True while an elbow segment is being dragged. */
4285
+ get isDraggingSegment() {
4286
+ return this.linkSegmentDrag !== null;
4287
+ }
4288
+ /**
4289
+ * Begin a host-managed elbow segment drag. `axis` is the segment's
4290
+ * orientation; `at` is its centre along its own axis (used to re-identify it
4291
+ * across re-routes).
4292
+ */
4293
+ beginSegmentDrag(linkId, axis, at) {
4294
+ if (!getLink(this._scene, linkId))
4295
+ return;
4296
+ this.linkSegmentDrag = { linkId, axis, at };
4297
+ }
4298
+ /**
4299
+ * Move the dragged elbow segment perpendicular to its axis: pin its
4300
+ * perpendicular coordinate to the cursor. The reroute pass re-flows the
4301
+ * rest around the pin (one undo step via the gesture transaction).
4302
+ */
4303
+ updateSegmentDrag(world) {
4304
+ const drag = this.linkSegmentDrag;
4305
+ if (!drag)
4306
+ return;
4307
+ // A real drag breaks the handle double-click chain, so a single click
4308
+ // right after pinning can't be misread as a double-click (= delete).
4309
+ this.lastHandleClickAt = 0;
4310
+ const edge = getLink(this._scene, drag.linkId);
4311
+ if (!edge)
4312
+ return;
4313
+ const pos = drag.axis === "h" ? world.y : world.x;
4314
+ const fixed = [...(edge.fixedSegments ?? [])];
4315
+ const entry = { axis: drag.axis, pos, at: drag.at };
4316
+ const at = fixed.findIndex((f) => f.axis === drag.axis && Math.abs(f.at - drag.at) < 0.5);
4317
+ if (at >= 0)
4318
+ fixed[at] = entry;
4319
+ else
4320
+ fixed.push(entry);
4321
+ const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, fixedSegments: fixed }));
4322
+ this._scene = r.scene;
4323
+ this.recordGesturePatch(r.patch);
4324
+ this.notify();
4325
+ }
4326
+ /** Finish the elbow segment drag (commit the gesture as one undo step). */
4327
+ endSegmentDrag() {
4328
+ if (!this.linkSegmentDrag)
4329
+ return;
4330
+ this.linkSegmentDrag = null;
4331
+ this.commitGesture();
4332
+ }
4333
+ /**
4334
+ * Double-click detector for link edit handles (waypoint / segment).
4335
+ * Returns true when this press follows the previous handle press within
4336
+ * the double-click window + tolerance. Updates state every call. Kept
4337
+ * separate from the up-side double-click path (handles return early in
4338
+ * `onDown`, so that path never sees them).
4339
+ */
4340
+ isHandleDoubleClick(world) {
4341
+ const now = performance.now();
4342
+ const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
4343
+ this.lastHandleClickWorld !== null &&
4344
+ distanceTo(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
4345
+ this.lastHandleClickAt = now;
4346
+ this.lastHandleClickWorld = world;
4347
+ return isDouble;
4348
+ }
4349
+ /**
4350
+ * Delete a free bend point (waypoint) from a straight / bezier link by
4351
+ * index — double-click a waypoint handle to remove it. One undo step.
4352
+ */
4353
+ deleteWaypoint(linkId, index) {
4354
+ const edge = getLink(this._scene, linkId);
4355
+ if (!edge?.waypoints || index < 0 || index >= edge.waypoints.length)
4356
+ return;
4357
+ const wps = edge.waypoints.filter((_, i) => i !== index);
4358
+ const r = updateLink(this._scene, linkId, (e) => ({ ...e, waypoints: wps }));
4359
+ this._scene = r.scene;
4360
+ this._history.push(r.patch);
4361
+ this.notify();
4362
+ }
4363
+ /**
4364
+ * Remove the pinned (fixed) elbow segment that matches the given
4365
+ * geometry — double-click a segment handle to return it to the auto
4366
+ * route. Matches by axis + nearest pinned perpendicular `pos` (exact for
4367
+ * a pinned segment), `at` as tiebreak. The reroute pass re-flows on the
4368
+ * next render (fixedSegments is part of the elbow signature). One undo
4369
+ * step.
4370
+ */
4371
+ resetSegmentPin(linkId, axis, pos, at) {
4372
+ const edge = getLink(this._scene, linkId);
4373
+ if (!edge?.fixedSegments || edge.fixedSegments.length === 0)
4374
+ return;
4375
+ let bestIdx = -1;
4376
+ let bestD = Infinity;
4377
+ for (let i = 0; i < edge.fixedSegments.length; i++) {
4378
+ const f = req(edge.fixedSegments[i]);
4379
+ if (f.axis !== axis)
4380
+ continue;
4381
+ const d = Math.abs(f.pos - pos) + Math.abs(f.at - at) * 0.001;
4382
+ if (d < bestD) {
4383
+ bestD = d;
4384
+ bestIdx = i;
4385
+ }
4386
+ }
4387
+ if (bestIdx < 0)
4388
+ return;
4389
+ const fixed = edge.fixedSegments.filter((_, i) => i !== bestIdx);
4390
+ const r = updateLink(this._scene, linkId, (e) => ({ ...e, fixedSegments: fixed }));
4391
+ this._scene = r.scene;
4392
+ this._history.push(r.patch);
4393
+ this.notify();
4394
+ }
4395
+ /** Whether the selected link has obstacle-avoidance routing enabled. */
4396
+ get selectedLinkAvoidsObstacles() {
4397
+ const id = this.selectedLink;
4398
+ if (id === null)
4399
+ return false;
4400
+ return getLink(this._scene, id)?.avoidObstacles === true;
4401
+ }
4402
+ /**
4403
+ * Toggle persistent "route around shapes" on the selected link (standard
4404
+ * model). Enabling sets `avoidObstacles` and forces `orthogonal` routing —
4405
+ * the elbow router then keeps the path clear of EVERY scene shape and
4406
+ * re-routes whenever an obstacle moves into the way (see
4407
+ * `routeElbowLink` / `elbowSignature`). Disabling drops the flag; the
4408
+ * routing type is left as-is. One undo step; the routed path itself is
4409
+ * derived (recomputed by `rerouteElbows`). No-op when no link is selected.
4410
+ */
4411
+ setSelectedLinkAvoidObstacles(enabled) {
4412
+ const id = this.selectedLink;
4413
+ if (id === null)
4414
+ return;
4415
+ const edge = getLink(this._scene, id);
4416
+ if (!edge || edge.avoidObstacles === enabled)
4417
+ return;
4418
+ const r = updateLink(this._scene, id, (e) => ({
4419
+ ...e,
4420
+ avoidObstacles: enabled,
4421
+ ...(enabled ? { routing: "orthogonal" } : {}),
4422
+ }));
4423
+ this._scene = r.scene;
4424
+ this._history.push(r.patch);
4425
+ // Force the next reroute to recompute with the new mode.
4426
+ this.elbowRouteSig.delete(id);
4427
+ this.notify();
4428
+ }
4429
+ updateHoveredLinkTarget(worldPoint) {
4430
+ const shape = this.acceleratedElementAt(worldPoint);
4431
+ if (!shape) {
4432
+ if (this.hoveredLinkTarget !== null) {
4433
+ this.hoveredLinkTarget = null;
4434
+ this.notify();
4435
+ }
4436
+ return;
4437
+ }
4438
+ const result = this.snapEngine.snap({
4439
+ scene: this._scene,
4440
+ probe: worldPoint,
4441
+ threshold: this.snapThreshold,
4442
+ gesture: "draw-edge",
4443
+ });
4444
+ // Prefer a snap candidate that belongs to the current shape —
4445
+ // ensures the "ghost" points don't jump to a nearby shape.
4446
+ const onTarget = result.all.filter((c) => c.metadata?.elementId === shape.id);
4447
+ const anchor = onTarget.find((c) => c.kind === "anchor");
4448
+ const outline = onTarget.find((c) => c.kind === "outline");
4449
+ const ref = anchor?.metadata?.ref;
4450
+ let activeName = null;
4451
+ if (ref?.kind === "named") {
4452
+ activeName = ref.name;
4453
+ }
4454
+ else if (ref?.kind === "edge" && ref.t === 0.5) {
4455
+ activeName = `edge-${ref.index}`;
4456
+ }
4457
+ const outlinePoint = !activeName && outline ? outline.snapped : undefined;
4458
+ const prev = this.hoveredLinkTarget;
4459
+ if (prev?.elementId === shape.id &&
4460
+ prev.activeAnchor === activeName &&
4461
+ prev.outlinePoint?.x === outlinePoint?.x &&
4462
+ prev.outlinePoint?.y === outlinePoint?.y) {
4463
+ return;
4464
+ }
4465
+ // Mode mirrors snapLinkEndpoint: a named-anchor OR edge (outline) hit →
4466
+ // *fixed* point (show the dot, no float halo); only the body interior with
4467
+ // no edge/dot snap → floating (attach to the whole element).
4468
+ const mode = activeName !== null || outlinePoint ? "point" : "element";
4469
+ this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
4470
+ this.notify();
4471
+ }
4472
+ // Pure body in `./editor/applies/edge.ts`.
4473
+ applyLinkPreview(fromElement, fromPoint, toPoint) {
4474
+ const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
4475
+ // Match the preview to the connector that will be committed: when new
4476
+ // links default to elbow, draw the orthogonal route, not a straight line.
4477
+ if (DEFAULT_LINK_ROUTING === "orthogonal") {
4478
+ const hit = this.hitTest(ep.to);
4479
+ const toElement = hit.kind === "element" ? hit.id : null;
4480
+ const points = routeElbowPreview(this._scene, fromElement, ep.from, toElement, ep.to);
4481
+ this.edgePreview = { ...ep, points };
4482
+ }
4483
+ else {
4484
+ this.edgePreview = ep;
4485
+ }
4486
+ this.notify();
4487
+ }
4488
+ // Gesture lifecycle — recordGesturePatch / commitGesture /
4489
+ // cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate
4490
+ // live in `./editor/gesture-tx.ts`. The thin instance methods below
4491
+ // preserve the original call sites.
4492
+ recordGesturePatch(patch) {
4493
+ // Snapshot the pre-gesture scene the moment the transaction opens, so a
4494
+ // later cancel/Escape can restore it (the history tx only records undo data,
4495
+ // it doesn't roll `_scene` back). Callers apply the patch to `_scene` BEFORE
4496
+ // recording, so reconstruct the pre-state by inverting this first patch.
4497
+ if (this.gestureTx === null)
4498
+ this.gestureStartScene = apply(this._scene, invert(patch));
4499
+ this.gestures.record(patch);
4500
+ }
4501
+ commitGesture() {
4502
+ this._resizeOriginElement = null;
4503
+ this.gestures.commit();
4504
+ this.gestureStartScene = null;
4505
+ }
4506
+ finalizeOpenGestureTx() {
4507
+ this.gestures.finalize();
4508
+ }
4509
+ /**
4510
+ * End-of-drag container hookup. Runs after the state machine has
4511
+ * received POINTER_UP but before the gesture transaction commits,
4512
+ * so reparent + auto-grow land in one undo step with the drag itself.
4513
+ *
4514
+ * Rules:
4515
+ * - If the shape was dropped over a container and is not yet its child →
4516
+ * set `parentId`. If the shape extends past the dropZone, the zone is
4517
+ * grown (and the container's size with it).
4518
+ * - If the shape was someone's child but its final world bounds no longer
4519
+ * intersect the parent's drop-zone → clear `parentId` (drag-out).
4520
+ * - Cycles (a container inside its own descendant) are prevented by the
4521
+ * `containerHover` pipeline above — the exclude set rules them out.
4522
+ */
4523
+ // Pure body in `./editor/container-ops.ts`. Editor exposes a
4524
+ // small `ContainerOpsRef` bridge so the module can mutate scene
4525
+ // + push patches into the running gesture transaction.
4526
+ applyContainerDrop(worldPoint) {
4527
+ applyContainerDropPure(this.containerOpsRef, worldPoint);
4528
+ }
4529
+ // Public-private hybrid — also called from AutoLayoutScheduler.
4530
+ maybeGrowContainer(containerId, childId) {
4531
+ maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
4532
+ }
4533
+ // Pure body in `./editor/container-ops.ts`.
4534
+ clampContainerToChildren(shape, raw, handle) {
4535
+ return clampContainerToChildrenPure(this._scene, shape, raw, handle);
4536
+ }
4537
+ /**
4538
+ * Return the running gesture tx, or open a new one if the drag finished
4539
+ * with an empty transaction (a move-by-zero-pixels gesture can still
4540
+ * carry a container reparent).
4541
+ */
4542
+ beginOrAttachGesture() {
4543
+ this.gestureTx ??= this._history.transaction();
4544
+ return this.gestureTx;
4545
+ }
4546
+ // Body moved to `./editor/gesture-tx.ts`.
4547
+ cancelGesture() {
4548
+ this._resizeOriginElement = null;
4549
+ this.gestures.cancel();
4550
+ // Roll the scene back to the pre-gesture snapshot — cancelling the history
4551
+ // transaction alone leaves the live drag mutations in `_scene`.
4552
+ if (this.gestureStartScene !== null) {
4553
+ this._scene = this.gestureStartScene;
4554
+ this.gestureStartScene = null;
4555
+ }
4556
+ }
4557
+ /**
4558
+ * Drop ids from the selection that no longer exist in the scene. Needed
4559
+ * after undoing a CREATE — the shape goes away and the selection becomes
4560
+ * stale.
4561
+ */
4562
+ pruneSelection() {
4563
+ let next = null;
4564
+ for (const id of this._selection) {
4565
+ if (!this._scene.elements.has(id)) {
4566
+ next ??= new Set(this._selection);
4567
+ next.delete(id);
4568
+ }
4569
+ }
4570
+ if (next !== null)
4571
+ this._selection = next;
4572
+ }
4573
+ notify() {
4574
+ this.scheduleRender();
4575
+ fanOutEvents(this.eventCache, this.events, this.observableSnapshot());
4576
+ for (const fn of this.listeners)
4577
+ fn();
4578
+ this.autoCompactScheduler.schedule();
4579
+ this.autoLayoutScheduler.schedule();
4580
+ // A pan / zoom / scene edit may have scrolled an animated shape into
4581
+ // view — re-arm the (viewport-culled) animation tick.
4582
+ this.maybeAnimate();
4583
+ }
4584
+ /**
4585
+ * Pending `requestAnimationFrame` id for the next render, or null
4586
+ * when no render is scheduled. Used to coalesce bursts of `notify()`
4587
+ * calls (drag-pan, drag shape, multi-key, scripted batch mutations)
4588
+ * into a single render per frame.
4589
+ */
4590
+ renderRafId = null;
4591
+ /** Unsubscribe for the animation-content-ready listener (decode → re-render). */
4592
+ animationContentOff = null;
4593
+ /**
4594
+ * Schedule a render on the next animation frame. Idempotent —
4595
+ * multiple calls within the same frame collapse to one render.
4596
+ *
4597
+ * Falls back to a synchronous render when `requestAnimationFrame`
4598
+ * is unavailable (Node without jsdom, SSR). Browser / test environments
4599
+ * with rAF get the coalesced path.
4600
+ *
4601
+ * Use {@link forceRender} when you need the render to happen
4602
+ * immediately (PNG export, screenshot, visual-regression tests that
4603
+ * compare bitmap output after a mutation).
4604
+ */
4605
+ scheduleRender() {
4606
+ if (this.renderRafId !== null)
4607
+ return;
4608
+ if (typeof requestAnimationFrame === "undefined") {
4609
+ // SSR / Node fallback. Keep behaviour synchronous so headless
4610
+ // renderers and tests that don't poll rAFs still see the
4611
+ // updated frame.
4612
+ this.render();
4613
+ return;
4614
+ }
4615
+ this.renderRafId = requestAnimationFrame(() => {
4616
+ this.renderRafId = null;
4617
+ this.render();
4618
+ });
4619
+ }
4620
+ /**
4621
+ * Synchronously render the current state. Cancels any pending
4622
+ * rAF-scheduled render so the next browser frame doesn't paint
4623
+ * a stale state on top.
4624
+ *
4625
+ * Hosts only need this when they read back the rendered bitmap
4626
+ * immediately after a mutation — `editor.toPng()`, custom
4627
+ * `canvas.toDataURL()` flows, visual-regression test asserts.
4628
+ * Normal interactive flows should let `scheduleRender` do its job.
4629
+ */
4630
+ forceRender() {
4631
+ if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
4632
+ cancelAnimationFrame(this.renderRafId);
4633
+ this.renderRafId = null;
4634
+ }
4635
+ this.render();
4636
+ }
4637
+ /**
4638
+ * Typed event surface — subscribe to a specific slice (`mode`,
4639
+ * `selection`, `scene`, `history`, `viewport`) or the umbrella
4640
+ * `change`. Replaces ad-hoc selectors over the coarse `subscribe()`
4641
+ * for callers that only care about one dimension. The legacy
4642
+ * `subscribe()` still works and fires in lock-step.
4643
+ */
4644
+ on(event, fn) {
4645
+ // Cast through `never`: TS can't prove that EditorEvents[K]
4646
+ // satisfies the emitter's `extends AnyListener ? T : never`
4647
+ // conditional through a generic body. Every entry of
4648
+ // EditorEvents is a function by construction so this is safe.
4649
+ return this.events.on(event, fn);
4650
+ }
4651
+ off(event, fn) {
4652
+ this.events.off(event, fn);
4653
+ }
4654
+ /**
4655
+ * Public command — re-run the auto-layout spec on the given
4656
+ * container shape, regardless of whether the children set actually
4657
+ * changed. Useful as an "auto-arrange" toolbar action or for hosts
4658
+ * adopting auto-layout after creating shapes programmatically.
4659
+ * Returns the patch that was applied, or `null` when nothing
4660
+ * changed (no spec, no children, or children already in position).
4661
+ * Single undo step.
4662
+ */
4663
+ runLayout(parentId) {
4664
+ const patch = runAutoLayout(this._scene, parentId);
4665
+ if (!patch)
4666
+ return null;
4667
+ this._scene = apply(this._scene, patch);
4668
+ this._history.push(patch);
4669
+ // Refresh the recorded signature so the post-notify auto-check
4670
+ // doesn't fire a second redundant run.
4671
+ this.autoLayoutScheduler.resetSignature(parentId);
4672
+ this.notify();
4673
+ return patch;
4674
+ }
4675
+ /**
4676
+ * Per-link signature of the inputs that determine an elbow route
4677
+ * (endpoint refs + bound-shape bounds + fixedSegments). When unchanged
4678
+ * between frames the A* route is reused — see `rerouteElbows`.
4679
+ */
4680
+ elbowRouteSig = new Map();
4681
+ elbowSignature(edge) {
4682
+ const part = (ep) => {
4683
+ if (ep.kind === "point")
4684
+ return `p:${ep.position.x},${ep.position.y}`;
4685
+ const s = getElement(this._scene, ep.elementId);
4686
+ const b = s ? getElementWorldBounds(s) : null;
4687
+ const ref = ep.kind === "anchor"
4688
+ ? JSON.stringify(ep.anchor)
4689
+ : ep.kind === "outline"
4690
+ ? `o:${ep.ratio}`
4691
+ : "f";
4692
+ return `${ep.kind}:${ep.elementId}:${ref}:${b ? `${b.x},${b.y},${b.width},${b.height}` : "x"}`;
4693
+ };
4694
+ const base = `${part(edge.from)}|${part(edge.to)}|${JSON.stringify(edge.fixedSegments ?? null)}`;
4695
+ // Avoid-obstacles links depend on EVERY shape's geometry, so their route
4696
+ // must invalidate when any obstacle moves — fold a digest of all element
4697
+ // bboxes into the signature. Only paid by links that opt in.
4698
+ if (edge.avoidObstacles === true) {
4699
+ let digest = "|avoid:";
4700
+ for (const el of this._scene.elements.values()) {
4701
+ const bb = getElementWorldBounds(el);
4702
+ digest += `${el.id},${bb.x},${bb.y},${bb.width},${bb.height};`;
4703
+ }
4704
+ return base + digest;
4705
+ }
4706
+ return base;
4707
+ }
4708
+ /**
4709
+ * Choke-point reroute (standard model): recompute `routedPoints` for
4710
+ * every orthogonal link whose inputs changed since the last pass, and
4711
+ * bake the result into `_scene`. Runs once per frame before paint —
4712
+ * derived state, so no history push / notify (would loop). Cheap when
4713
+ * nothing moved (signature short-circuit).
4714
+ */
4715
+ rerouteElbows() {
4716
+ let next = this._scene;
4717
+ for (const [id, edge] of this._scene.links) {
4718
+ if ((edge.routing ?? "straight") !== "orthogonal")
4719
+ continue;
4720
+ const sig = this.elbowSignature(edge);
4721
+ if (this.elbowRouteSig.get(id) === sig)
4722
+ continue;
4723
+ this.elbowRouteSig.set(id, sig);
4724
+ const routedPoints = routeElbowLink(next, edge);
4725
+ next = updateLink(next, id, (e) => ({ ...e, routedPoints })).scene;
4726
+ }
4727
+ this._scene = next;
4728
+ }
4729
+ // Pure body in `./editor/render-orchestrator.ts` (~130 lines).
4730
+ render() {
4731
+ this.rerouteElbows();
4732
+ // Feed the renderer's animation clock our per-shape playback state
4733
+ // so paused / reduced-motion GIFs freeze and resumed ones continue
4734
+ // from the right frame. Set immediately before the synchronous
4735
+ // render pass (the shape-renderer has no options channel).
4736
+ setAnimationClock((shape) => this.playbackClock(shape.id));
4737
+ renderEditor(this);
4738
+ // Present AFTER the paint, on the same tick — deferred-submission
4739
+ // surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
4740
+ this.onAfterRender?.();
4741
+ }
4742
+ }
4743
+ /**
4744
+ * Throwaway id for the transient click-create ghost preview element built by
4745
+ * `previewClickCreate`. Never enters the scene / history — it lives only for
4746
+ * the duration of one overlay paint, so any stable constant is fine.
4747
+ */
4748
+ const PREVIEW_GHOST_ELEMENT_ID = "__ghost-preview__";
4749
+ /** Throwaway link id for the click-create ghost preview. See above. */
4750
+ const PREVIEW_GHOST_LINK_ID = "__ghost-preview-link__";
4751
+ const distanceTo = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
4752
+ // `coverageRatio` moved to `./editor/container-ops.ts`.
4753
+ // `hasWidthHeight` moved to `./editor/shape-traits.ts` for shared
4754
+ // use by container-ops and the future applies/resize module.
4755
+ /**
4756
+ * Type guard — `true` when the value already implements the
4757
+ * `HistoryProvider` surface. Used to decide between "host supplied
4758
+ * an existing backend (use it as-is)" and "host supplied options
4759
+ * (build a default `History`)". Checks the methods that every
4760
+ * provider must expose; missing methods → treat as options.
4761
+ */
4762
+ const isHistoryProvider = (value) => {
4763
+ if (!value || typeof value !== "object")
4764
+ return false;
4765
+ return (typeof value.push === "function" &&
4766
+ typeof value.undo === "function" &&
4767
+ typeof value.redo === "function" &&
4768
+ typeof value.transaction === "function");
4769
+ };
4770
+ // `describeNudge` moved to `./editor/public/selection-ops.ts`.
4771
+ /** Distance from point `p` to the finite segment `a`–`b` (world space). */
4772
+ function distanceToSegmentPt(p, a, b) {
4773
+ const dx = b.x - a.x;
4774
+ const dy = b.y - a.y;
4775
+ const lenSq = dx * dx + dy * dy;
4776
+ if (lenSq === 0)
4777
+ return Math.hypot(p.x - a.x, p.y - a.y);
4778
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
4779
+ t = Math.max(0, Math.min(1, t));
4780
+ return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
4781
+ }
4782
+ /**
4783
+ * Convert a snap candidate into an `LinkEndpoint`. Anchor snap → named
4784
+ * anchor ref; outline snap → outline ref with the sampled ratio. Falls
4785
+ * back to a free point if the metadata isn't recognised.
4786
+ */
4787
+ const endpointFromSnap = (elementId, candidate, shape) => {
4788
+ if (candidate.kind === "anchor") {
4789
+ const ref = candidate.metadata?.ref;
4790
+ if (ref)
4791
+ return { kind: "anchor", elementId, anchor: ref };
4792
+ }
4793
+ if (candidate.kind === "outline" && typeof candidate.metadata?.ratio === "number") {
4794
+ return { kind: "outline", elementId, ratio: candidate.metadata.ratio };
4795
+ }
4796
+ // Defensive fallback — should not happen with built-in contributors.
4797
+ void shape;
4798
+ return { kind: "point", position: candidate.snapped };
4799
+ };
4800
+ // `resizeFromHandle`, `applyResizeConstraints`, the four handle-
4801
+ // quadrant predicates moved to `./editor/resize-helpers.ts` so
4802
+ // they're shared between applies/resize and the container clamp.
4803
+ //# sourceMappingURL=editor.js.map