@oh-just-another/state 0.57.0 → 0.59.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 (233) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +76 -27
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/actions/actionArrange.d.ts +13 -0
  5. package/dist/actions/actionArrange.d.ts.map +1 -0
  6. package/dist/actions/actionArrange.js +130 -0
  7. package/dist/actions/actionArrange.js.map +1 -0
  8. package/dist/actions/actionClipboard.d.ts +2 -0
  9. package/dist/actions/actionClipboard.d.ts.map +1 -1
  10. package/dist/actions/actionClipboard.js +36 -4
  11. package/dist/actions/actionClipboard.js.map +1 -1
  12. package/dist/actions/actionGrouping.d.ts.map +1 -1
  13. package/dist/actions/actionGrouping.js +3 -1
  14. package/dist/actions/actionGrouping.js.map +1 -1
  15. package/dist/actions/actionKeyboard.d.ts.map +1 -1
  16. package/dist/actions/actionKeyboard.js +6 -2
  17. package/dist/actions/actionKeyboard.js.map +1 -1
  18. package/dist/actions/actionLayout.d.ts.map +1 -1
  19. package/dist/actions/actionLayout.js +12 -4
  20. package/dist/actions/actionLayout.js.map +1 -1
  21. package/dist/actions/actionMode.d.ts.map +1 -1
  22. package/dist/actions/actionMode.js +30 -10
  23. package/dist/actions/actionMode.js.map +1 -1
  24. package/dist/actions/actionSelection.d.ts.map +1 -1
  25. package/dist/actions/actionSelection.js +18 -6
  26. package/dist/actions/actionSelection.js.map +1 -1
  27. package/dist/actions/actionText.d.ts +5 -0
  28. package/dist/actions/actionText.d.ts.map +1 -0
  29. package/dist/actions/actionText.js +37 -0
  30. package/dist/actions/actionText.js.map +1 -0
  31. package/dist/actions/actionView.d.ts.map +1 -1
  32. package/dist/actions/actionView.js +4 -2
  33. package/dist/actions/actionView.js.map +1 -1
  34. package/dist/actions/actionZOrder.d.ts.map +1 -1
  35. package/dist/actions/actionZOrder.js +12 -4
  36. package/dist/actions/actionZOrder.js.map +1 -1
  37. package/dist/actions/actionZoom.d.ts.map +1 -1
  38. package/dist/actions/actionZoom.js +17 -17
  39. package/dist/actions/actionZoom.js.map +1 -1
  40. package/dist/actions/index.d.ts +3 -1
  41. package/dist/actions/index.d.ts.map +1 -1
  42. package/dist/actions/index.js +3 -1
  43. package/dist/actions/index.js.map +1 -1
  44. package/dist/actions/registry.d.ts.map +1 -1
  45. package/dist/actions/registry.js +5 -3
  46. package/dist/actions/registry.js.map +1 -1
  47. package/dist/actions/types.d.ts +1 -1
  48. package/dist/actions/types.d.ts.map +1 -1
  49. package/dist/actions/types.js.map +1 -1
  50. package/dist/auto-layout-scheduler.d.ts +1 -3
  51. package/dist/auto-layout-scheduler.d.ts.map +1 -1
  52. package/dist/auto-layout-scheduler.js +1 -2
  53. package/dist/auto-layout-scheduler.js.map +1 -1
  54. package/dist/built-in-handlers.d.ts.map +1 -1
  55. package/dist/built-in-handlers.js +30 -7
  56. package/dist/built-in-handlers.js.map +1 -1
  57. package/dist/clipboard.d.ts.map +1 -1
  58. package/dist/clipboard.js +6 -7
  59. package/dist/clipboard.js.map +1 -1
  60. package/dist/constants.d.ts +48 -23
  61. package/dist/constants.d.ts.map +1 -1
  62. package/dist/constants.js +48 -23
  63. package/dist/constants.js.map +1 -1
  64. package/dist/dom-events.d.ts +7 -0
  65. package/dist/dom-events.d.ts.map +1 -1
  66. package/dist/dom-events.js +12 -0
  67. package/dist/dom-events.js.map +1 -1
  68. package/dist/editor/animation-scene.d.ts +21 -0
  69. package/dist/editor/animation-scene.d.ts.map +1 -0
  70. package/dist/editor/animation-scene.js +71 -0
  71. package/dist/editor/animation-scene.js.map +1 -0
  72. package/dist/editor/animation.d.ts +43 -0
  73. package/dist/editor/animation.d.ts.map +1 -0
  74. package/dist/editor/animation.js +80 -0
  75. package/dist/editor/animation.js.map +1 -0
  76. package/dist/editor/applies/arrange.d.ts +46 -0
  77. package/dist/editor/applies/arrange.d.ts.map +1 -0
  78. package/dist/editor/applies/arrange.js +162 -0
  79. package/dist/editor/applies/arrange.js.map +1 -0
  80. package/dist/editor/applies/create.d.ts.map +1 -1
  81. package/dist/editor/applies/create.js +1 -1
  82. package/dist/editor/applies/create.js.map +1 -1
  83. package/dist/editor/applies/edge.d.ts +8 -1
  84. package/dist/editor/applies/edge.d.ts.map +1 -1
  85. package/dist/editor/applies/edge.js +31 -1
  86. package/dist/editor/applies/edge.js.map +1 -1
  87. package/dist/editor/applies/link-move.d.ts.map +1 -1
  88. package/dist/editor/applies/link-move.js +5 -2
  89. package/dist/editor/applies/link-move.js.map +1 -1
  90. package/dist/editor/applies/move.d.ts +6 -0
  91. package/dist/editor/applies/move.d.ts.map +1 -1
  92. package/dist/editor/applies/move.js +6 -0
  93. package/dist/editor/applies/move.js.map +1 -1
  94. package/dist/editor/applies/resize.d.ts +26 -5
  95. package/dist/editor/applies/resize.d.ts.map +1 -1
  96. package/dist/editor/applies/resize.js +92 -14
  97. package/dist/editor/applies/resize.js.map +1 -1
  98. package/dist/editor/applies/snap-grid.d.ts.map +1 -1
  99. package/dist/editor/applies/snap-grid.js.map +1 -1
  100. package/dist/editor/caret-blink.d.ts +18 -0
  101. package/dist/editor/caret-blink.d.ts.map +1 -0
  102. package/dist/editor/caret-blink.js +42 -0
  103. package/dist/editor/caret-blink.js.map +1 -0
  104. package/dist/editor/container-ops.d.ts.map +1 -1
  105. package/dist/editor/container-ops.js +2 -2
  106. package/dist/editor/container-ops.js.map +1 -1
  107. package/dist/editor/dirty-rect.d.ts +23 -0
  108. package/dist/editor/dirty-rect.d.ts.map +1 -0
  109. package/dist/editor/dirty-rect.js +115 -0
  110. package/dist/editor/dirty-rect.js.map +1 -0
  111. package/dist/editor/gif-playback.d.ts +45 -0
  112. package/dist/editor/gif-playback.d.ts.map +1 -0
  113. package/dist/editor/gif-playback.js +125 -0
  114. package/dist/editor/gif-playback.js.map +1 -0
  115. package/dist/editor/hit-test.d.ts +3 -3
  116. package/dist/editor/hit-test.d.ts.map +1 -1
  117. package/dist/editor/hit-test.js +40 -15
  118. package/dist/editor/hit-test.js.map +1 -1
  119. package/dist/editor/long-press.d.ts.map +1 -1
  120. package/dist/editor/long-press.js +2 -2
  121. package/dist/editor/long-press.js.map +1 -1
  122. package/dist/editor/pinch.d.ts.map +1 -1
  123. package/dist/editor/pinch.js +6 -7
  124. package/dist/editor/pinch.js.map +1 -1
  125. package/dist/editor/pointer-binding.d.ts.map +1 -1
  126. package/dist/editor/pointer-binding.js +42 -25
  127. package/dist/editor/pointer-binding.js.map +1 -1
  128. package/dist/editor/public/annotations.d.ts.map +1 -1
  129. package/dist/editor/public/annotations.js.map +1 -1
  130. package/dist/editor/public/arrange-group.d.ts +3 -9
  131. package/dist/editor/public/arrange-group.d.ts.map +1 -1
  132. package/dist/editor/public/arrange-group.js +4 -6
  133. package/dist/editor/public/arrange-group.js.map +1 -1
  134. package/dist/editor/public/brush.d.ts.map +1 -1
  135. package/dist/editor/public/brush.js +1 -3
  136. package/dist/editor/public/brush.js.map +1 -1
  137. package/dist/editor/public/clipboard.d.ts +2 -4
  138. package/dist/editor/public/clipboard.d.ts.map +1 -1
  139. package/dist/editor/public/clipboard.js +2 -4
  140. package/dist/editor/public/clipboard.js.map +1 -1
  141. package/dist/editor/public/cursor.d.ts +43 -0
  142. package/dist/editor/public/cursor.d.ts.map +1 -0
  143. package/dist/editor/public/cursor.js +135 -0
  144. package/dist/editor/public/cursor.js.map +1 -0
  145. package/dist/editor/public/frame-name.d.ts +20 -0
  146. package/dist/editor/public/frame-name.d.ts.map +1 -0
  147. package/dist/editor/public/frame-name.js +54 -0
  148. package/dist/editor/public/frame-name.js.map +1 -0
  149. package/dist/editor/public/image-insert.d.ts +1 -1
  150. package/dist/editor/public/image-insert.d.ts.map +1 -1
  151. package/dist/editor/public/layers.d.ts.map +1 -1
  152. package/dist/editor/public/layers.js +2 -2
  153. package/dist/editor/public/layers.js.map +1 -1
  154. package/dist/editor/public/link.d.ts +11 -2
  155. package/dist/editor/public/link.d.ts.map +1 -1
  156. package/dist/editor/public/link.js +71 -5
  157. package/dist/editor/public/link.js.map +1 -1
  158. package/dist/editor/public/placement.d.ts +60 -2
  159. package/dist/editor/public/placement.d.ts.map +1 -1
  160. package/dist/editor/public/placement.js +185 -2
  161. package/dist/editor/public/placement.js.map +1 -1
  162. package/dist/editor/public/selection-ops.d.ts +23 -1
  163. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  164. package/dist/editor/public/selection-ops.js +81 -8
  165. package/dist/editor/public/selection-ops.js.map +1 -1
  166. package/dist/editor/public/text-edit.d.ts +1 -10
  167. package/dist/editor/public/text-edit.d.ts.map +1 -1
  168. package/dist/editor/public/text-edit.js +2 -16
  169. package/dist/editor/public/text-edit.js.map +1 -1
  170. package/dist/editor/public/z-order.d.ts.map +1 -1
  171. package/dist/editor/public/z-order.js +5 -5
  172. package/dist/editor/public/z-order.js.map +1 -1
  173. package/dist/editor/public/zoom-pan.d.ts +6 -4
  174. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  175. package/dist/editor/public/zoom-pan.js +8 -7
  176. package/dist/editor/public/zoom-pan.js.map +1 -1
  177. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  178. package/dist/editor/render-orchestrator.js +5 -11
  179. package/dist/editor/render-orchestrator.js.map +1 -1
  180. package/dist/editor/resize-helpers.d.ts +21 -5
  181. package/dist/editor/resize-helpers.d.ts.map +1 -1
  182. package/dist/editor/resize-helpers.js +59 -19
  183. package/dist/editor/resize-helpers.js.map +1 -1
  184. package/dist/editor/viewport-helpers.d.ts.map +1 -1
  185. package/dist/editor/viewport-helpers.js +3 -3
  186. package/dist/editor/viewport-helpers.js.map +1 -1
  187. package/dist/editor-events.d.ts +1 -1
  188. package/dist/editor-events.d.ts.map +1 -1
  189. package/dist/editor.d.ts +125 -130
  190. package/dist/editor.d.ts.map +1 -1
  191. package/dist/editor.js +352 -985
  192. package/dist/editor.js.map +1 -1
  193. package/dist/file-drop.d.ts.map +1 -1
  194. package/dist/file-drop.js +7 -4
  195. package/dist/file-drop.js.map +1 -1
  196. package/dist/frame-helpers.d.ts.map +1 -1
  197. package/dist/frame-helpers.js +5 -5
  198. package/dist/frame-helpers.js.map +1 -1
  199. package/dist/group-helpers.d.ts.map +1 -1
  200. package/dist/group-helpers.js +4 -4
  201. package/dist/group-helpers.js.map +1 -1
  202. package/dist/handle.d.ts +61 -6
  203. package/dist/handle.d.ts.map +1 -1
  204. package/dist/handle.js +105 -7
  205. package/dist/handle.js.map +1 -1
  206. package/dist/hotkeys-binding.d.ts +26 -0
  207. package/dist/hotkeys-binding.d.ts.map +1 -0
  208. package/dist/hotkeys-binding.js +35 -0
  209. package/dist/hotkeys-binding.js.map +1 -0
  210. package/dist/index.d.ts +3 -2
  211. package/dist/index.d.ts.map +1 -1
  212. package/dist/index.js +3 -2
  213. package/dist/index.js.map +1 -1
  214. package/dist/link-selection.d.ts +3 -5
  215. package/dist/link-selection.d.ts.map +1 -1
  216. package/dist/link-selection.js +3 -12
  217. package/dist/link-selection.js.map +1 -1
  218. package/dist/machine.d.ts +27 -1
  219. package/dist/machine.d.ts.map +1 -1
  220. package/dist/machine.js +38 -0
  221. package/dist/machine.js.map +1 -1
  222. package/dist/overlay.d.ts +1 -4
  223. package/dist/overlay.d.ts.map +1 -1
  224. package/dist/overlay.js +142 -38
  225. package/dist/overlay.js.map +1 -1
  226. package/dist/selection.d.ts.map +1 -1
  227. package/dist/selection.js +2 -10
  228. package/dist/selection.js.map +1 -1
  229. package/dist/util.d.ts +8 -0
  230. package/dist/util.d.ts.map +1 -0
  231. package/dist/util.js +17 -0
  232. package/dist/util.js.map +1 -0
  233. package/package.json +9 -9
package/dist/editor.js CHANGED
@@ -1,30 +1,34 @@
1
1
  import { createActor } from "xstate";
2
2
  import { createEmitter } from "@oh-just-another/events";
3
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";
4
+ import { addElement, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, isFrame, isGroup, isText, isImage, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, removeElement, SnapEngine, isNoop, invert, updateLink, updateElement, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
5
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";
6
+ import { bounds as B, matrix, vec2, hitTest } from "@oh-just-another/math";
7
+ import { caretGeometry, layoutText, onAnimationContentReady, pointToCaretIndex, selectionRects as textSelectionRects, setActiveRasterizer, setActiveTextShaper, setAnimationClock, ElementCache, } from "@oh-just-another/renderer-core";
8
8
  import { History, } from "@oh-just-another/history";
9
- import { ANCHOR_CLICK_NEW_ELEMENT_GAP, DEFAULT_LINK_ROUTING, WAYPOINT_COLLAPSE_RADIUS, } from "./constants.js";
9
+ import { DEFAULT_LINK_ROUTING, WAYPOINT_COLLAPSE_RADIUS } from "./constants.js";
10
10
  import { FileDropRegistry } from "./file-drop.js";
11
11
  import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
12
- import { AnimationTick } from "./animation-tick.js";
13
12
  import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
14
13
  import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
15
14
  import { AutoCompactScheduler } from "./auto-compact.js";
16
15
  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";
16
+ 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, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, DOUBLE_CLICK_MS, DOUBLE_CLICK_TOLERANCE_PX, WHEEL_ZOOM_STEP, ROTATE_SNAP_RADIANS, } from "./constants.js";
17
+ import { HANDLE_HIT_SLOP } from "./handle.js";
18
+ import { req } from "./util.js";
20
19
  import { interactionMachine, } from "./machine.js";
21
20
  import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
21
+ import { AnimationController } from "./editor/animation.js";
22
+ import { CaretBlinkController } from "./editor/caret-blink.js";
22
23
  import { GestureController } from "./editor/gesture-tx.js";
24
+ import { GifPlaybackController } from "./editor/gif-playback.js";
25
+ import * as animScene from "./editor/animation-scene.js";
26
+ import { computeSceneDirtyRect } from "./editor/dirty-rect.js";
23
27
  import { LongPressController } from "./editor/long-press.js";
24
28
  import { pickPressTarget } from "./editor/hit-test.js";
25
29
  import { PinchController } from "./editor/pinch.js";
26
30
  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";
31
+ import { computeGroupResizePatches, computeElementResize, computeRotatedElementResize, computeTextResize, } from "./editor/applies/resize.js";
28
32
  import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
29
33
  import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, newBrushId, } from "./editor/public/brush.js";
30
34
  import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
@@ -32,55 +36,27 @@ import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, co
32
36
  import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, } from "./editor/public/zoom-pan.js";
33
37
  import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
34
38
  import { canBeginTextEdit } from "./editor/public/text-edit.js";
39
+ import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
40
+ import { computeCursor } from "./editor/public/cursor.js";
35
41
  import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
36
42
  import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
37
43
  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";
44
+ import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
45
+ import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
46
+ import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
41
47
  import { renderEditor } from "./editor/render-orchestrator.js";
42
48
  import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
43
49
  import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
44
50
  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";
51
+ import { computeLinkEndpointUpdate, computeLinkPreviewEndpoints, elbowSignature, } from "./editor/applies/edge.js";
52
+ import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, constrainDeltaToAxis, } from "./editor/applies/move.js";
53
+ import { computeAlignPatches, computeDistributePatches, computeFlipPatches, computeRotatePatches, selectionCenter, } from "./editor/applies/arrange.js";
47
54
  import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
48
55
  import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
49
56
  import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
50
57
  import {} from "./overlay.js";
51
58
  import * as Selection from "./selection.js";
52
59
  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
60
  export class Editor {
85
61
  host;
86
62
  mainTarget;
@@ -94,11 +70,6 @@ export class Editor {
94
70
  * View-only — never persisted or recorded in history.
95
71
  */
96
72
  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
73
  actor;
103
74
  listeners = new Set();
104
75
  /**
@@ -113,7 +84,7 @@ export class Editor {
113
84
  events = createEmitter();
114
85
  /**
115
86
  * Last-emitted snapshot of every observable slice. Used by
116
- * `fanOutEvents` (in `editor/event-fanout.ts`) to decide which
87
+ * `fanOutEvents` to decide which
117
88
  * typed events to fire on each `notify()` — only the slices
118
89
  * whose identity changed since the previous notify get an event.
119
90
  */
@@ -235,6 +206,13 @@ export class Editor {
235
206
  * position / size against the combined bounds delta each frame.
236
207
  */
237
208
  groupResizeOrigin = null;
209
+ /**
210
+ * Press-time snapshot for a rotate gesture: the pivot (selection bbox centre)
211
+ * and every member's pristine `position` / `rotation`. Each frame rotates
212
+ * from this baseline so the cumulative angle never drifts. Cleared on gesture
213
+ * end (commit / cancel).
214
+ */
215
+ rotateGestureOrigin = null;
238
216
  /**
239
217
  * Pristine shape snapshot for a single-shape text resize, captured on
240
218
  * the gesture's first tick. Font scaling is computed against this base
@@ -270,6 +248,20 @@ export class Editor {
270
248
  * move / resize / create wrappers; never persisted.
271
249
  */
272
250
  snapSuppressed = false;
251
+ /**
252
+ * Transient transform-modifier state mirrored from the host while a drag is
253
+ * in flight. `alt` resizes symmetrically about the centre; `shift` locks the
254
+ * resize aspect ratio or constrains a move to a single axis. Read by the
255
+ * move / resize wrappers; never persisted.
256
+ */
257
+ transformAltKey = false;
258
+ transformShiftKey = false;
259
+ /**
260
+ * In-editor style memory for copy-style / paste-style. Holds the visual
261
+ * `style` (fill / stroke / dash / …) captured from a shape; `null` until a
262
+ * copy happens. Not the OS clipboard — a lightweight per-editor buffer.
263
+ */
264
+ styleClipboard = null;
273
265
  /**
274
266
  * Persistent world-bounds cache shared with `renderScene` for viewport
275
267
  * culling. Object-identity keyed — invalidates automatically whenever
@@ -343,7 +335,6 @@ export class Editor {
343
335
  * Fractional-order compaction scheduler (microtask-coalesced).
344
336
  * Triggered from every `notify()`; only does real work when at
345
337
  * least one shape/edge order string crossed AUTO_COMPACT_THRESHOLD.
346
- * See `./auto-compact.ts` for the extracted logic.
347
338
  */
348
339
  autoCompactScheduler = new AutoCompactScheduler({
349
340
  getScene: () => this._scene,
@@ -353,8 +344,7 @@ export class Editor {
353
344
  });
354
345
  /**
355
346
  * Auto-layout scheduler — microtask-coalesced re-run of every
356
- * shape carrying `metadata.autoLayout`. See
357
- * `./auto-layout-scheduler.ts` for the extracted logic.
347
+ * shape carrying `metadata.autoLayout`.
358
348
  */
359
349
  autoLayoutScheduler = new AutoLayoutScheduler({
360
350
  getScene: () => this._scene,
@@ -429,10 +419,10 @@ export class Editor {
429
419
  * Screen-space origin point.
430
420
  */
431
421
  touchPanCandidate = null;
432
- // Pinch gesture state lives in PinchController (./editor/pinch.ts)
433
- // `pinch.isActive()` replaces the old `pinchOrigin !== null` check.
422
+ // Pinch gesture state lives in PinchController; `pinch.isActive()`
423
+ // reports whether a two-finger gesture is in flight.
434
424
  pinch;
435
- /** Bridge for `editor/container-ops.ts`. Built lazily in constructor. */
425
+ /** Bridge for the container-ops helpers. Built lazily in constructor. */
436
426
  containerOpsRef;
437
427
  /**
438
428
  * Space-bar held → next pointer drag pans the canvas instead of
@@ -501,9 +491,9 @@ export class Editor {
501
491
  * the timer fires. Hosts subscribe via `onLongPress` to surface a
502
492
  * context menu (mobile alternative to right-click).
503
493
  */
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.
494
+ // Long-press timer + origin live in LongPressController. The Set of
495
+ // subscribers stays here because `onLongPress` is part of the public
496
+ // Editor API.
507
497
  longPress;
508
498
  longPressListeners = new Set();
509
499
  /**
@@ -538,8 +528,7 @@ export class Editor {
538
528
  /**
539
529
  * Wraps gesture lifecycle (transaction open/commit/cancel +
540
530
  * 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
531
+ * The controller calls back through the narrow `GestureRef` bridge
543
532
  * built lazily below.
544
533
  */
545
534
  gestures;
@@ -699,8 +688,8 @@ export class Editor {
699
688
  }, (delta) => {
700
689
  this.panBy(delta);
701
690
  });
702
- // Bridge for container-ops module — narrow surface that the
703
- // pure functions in editor/container-ops.ts call back into.
691
+ // Bridge for the container-ops helpers — narrow surface that the
692
+ // pure functions call back into.
704
693
  // eslint-disable-next-line @typescript-eslint/no-this-alias -- bridge literal rebinds `this`; alias keeps Editor reference
705
694
  const self2 = this;
706
695
  this.containerOpsRef = {
@@ -723,13 +712,11 @@ export class Editor {
723
712
  // throttle rAF to ~1fps in background but don't stop it; an explicit
724
713
  // stop saves the decode + render entirely). Resume when visible again,
725
714
  // viewport permitting.
726
- if (typeof document !== "undefined") {
727
- document.addEventListener("visibilitychange", this.onVisibilityChange);
728
- }
715
+ this.animation.attach();
729
716
  // Restore GIF/video bytes onto animated image shapes loaded from
730
717
  // an initial scene (e.g. localStorage), then arm the tick so the
731
718
  // animation plays from first paint.
732
- this.rehydrateAnimatedImages();
719
+ animScene.rehydrateAnimatedImages(this);
733
720
  this.maybeAnimate();
734
721
  // An animated adapter (GIF) decodes asynchronously; when a decode
735
722
  // completes it nudges us here. Re-render so a PAUSED animated shape
@@ -822,7 +809,6 @@ export class Editor {
822
809
  this._selectedAnnotation = id;
823
810
  this.notify();
824
811
  }
825
- // Pure bodies in `./editor/public/annotations.ts`.
826
812
  addAnnotation(opts) {
827
813
  const result = computeAddAnnotation(this._scene, opts, this.commentAuthor, (p) => this.uniqueId(p));
828
814
  this._scene = result.scene;
@@ -934,16 +920,17 @@ export class Editor {
934
920
  this.debugHitZones = on;
935
921
  this.scheduleRender();
936
922
  }
937
- /** Show/hide the background grid (standard `g`). View-only not in history. */
923
+ /** Whether the background grid is enabled for the scene. */
924
+ get gridEnabled() {
925
+ return this._scene.viewport.gridEnabled;
926
+ }
927
+ /** Show/hide the background grid (`g`). Persists in the viewport, not in history. */
938
928
  setGridVisible(on) {
939
- if (this.gridVisible === on)
940
- return;
941
- this.gridVisible = on;
942
- this.scheduleRender();
929
+ this.setGrid({ enabled: on });
943
930
  }
944
- /** Toggle background grid visibility. */
931
+ /** Toggle the background grid on/off. */
945
932
  toggleGrid() {
946
- this.setGridVisible(!this.gridVisible);
933
+ this.setGrid({ enabled: !this.gridEnabled });
947
934
  }
948
935
  /** Whether the active draw-mode sticks after a create (toolbar lock). */
949
936
  get toolLocked() {
@@ -1016,7 +1003,6 @@ export class Editor {
1016
1003
  this._toolLocked = locked;
1017
1004
  this.notify();
1018
1005
  }
1019
- // Body moved to `./editor/gesture-tx.ts`.
1020
1006
  maybeRevertModeAfterCreate() {
1021
1007
  this.gestures.maybeRevertModeAfterCreate();
1022
1008
  }
@@ -1086,13 +1072,12 @@ export class Editor {
1086
1072
  * file-drop handler, a host CDN URL, an SVG string in
1087
1073
  * `image/svg+xml;base64,...` form.
1088
1074
  */
1089
- // Pure body in `./editor/public/image-insert.ts`.
1090
1075
  insertImage(input) {
1091
1076
  const id = castElementId(this.uniqueId("img"));
1092
1077
  const shape = buildImageElement(this._scene, input, id, this._activeLayerId);
1093
1078
  this.addElement(shape);
1094
1079
  if (input.animated) {
1095
- this.initPlayback(id);
1080
+ this.gifPlayback.ensure(id);
1096
1081
  this.maybeAnimate();
1097
1082
  }
1098
1083
  return id;
@@ -1110,67 +1095,25 @@ export class Editor {
1110
1095
  * current frame of natively-animated elements. Self-terminates
1111
1096
  * when no animated shapes remain.
1112
1097
  *
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.
1098
+ * Lifecycle managed by the `AnimationTick` helper.
1099
+ * `insertImage({animated:true})` and `loadScene` start the tick;
1100
+ * `dispose()` stops it.
1101
+ */
1102
+ animation = new AnimationController({
1103
+ hasVisibleAnimatedElement: () => animScene.hasVisibleAnimatedElement(this),
1104
+ autoStopHeavyGifs: () => {
1105
+ animScene.autoStopHeavyGifs(this);
1106
+ },
1107
+ forceAnimationRepaint: () => {
1108
+ // The scene reference hasn't changed, but the adapter advanced the GIF
1109
+ // frame drop the paint cache and force a repaint.
1142
1110
  this.lastRenderedScene = null;
1143
1111
  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
1112
  },
1148
1113
  });
1149
- // Pure body in `./editor/public/image-insert.ts`.
1150
1114
  hasAnimatedElement() {
1151
1115
  return hasAnimatedElement(this._scene);
1152
1116
  }
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
1117
  /**
1175
1118
  * Re-arm the animation tick after a change that may have brought an
1176
1119
  * animated shape into (or out of) view — pan / zoom / scene edit.
@@ -1178,93 +1121,21 @@ export class Editor {
1178
1121
  * `isAnimated()` is false, so this is cheap to call from `notify()`.
1179
1122
  */
1180
1123
  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
- }
1124
+ this.animation.maybe();
1218
1125
  }
1219
1126
  /**
1220
- * Seed playback for a freshly-animated shape. Start paused (frozen on
1221
- * frame 0) when the user prefers reduced motion; playing otherwise.
1127
+ * Per-shape GIF playback state (auto-stop + reduced-motion). Extracted into
1128
+ * a controller; the Editor keeps the orchestration (scene iteration,
1129
+ * animation tick, render scheduling) and delegates state ops here.
1222
1130
  */
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
- }
1131
+ gifPlayback = new GifPlaybackController();
1244
1132
  /**
1245
1133
  * Toggle GIF playback for a shape — wired to a click on an animated
1246
1134
  * image (resume after auto-stop, play after reduced-motion). Resuming
1247
1135
  * continues from the frozen frame.
1248
1136
  */
1249
1137
  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
- }
1138
+ this.gifPlayback.toggle(id);
1268
1139
  this.maybeAnimate();
1269
1140
  this.scheduleRender();
1270
1141
  }
@@ -1275,24 +1146,14 @@ export class Editor {
1275
1146
  * Pass `null` when the pointer leaves all shapes.
1276
1147
  */
1277
1148
  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
- }
1149
+ if (this.gifPlayback.hoverEnter(id)) {
1150
+ this.maybeAnimate();
1151
+ this.scheduleRender();
1291
1152
  }
1292
1153
  }
1293
1154
  /** True when the shape's GIF is paused (drives the overlay badge). */
1294
1155
  isPlaybackPaused(id) {
1295
- return this.playbackState.get(id)?.playing === false;
1156
+ return this.gifPlayback.isPaused(id);
1296
1157
  }
1297
1158
  /**
1298
1159
  * Record the idle cursor position so the overlay can grow the SINGLE
@@ -1315,74 +1176,6 @@ export class Editor {
1315
1176
  const t = this.hoveredLinkTarget;
1316
1177
  return t ? { elementId: t.elementId, mode: t.mode } : null;
1317
1178
  }
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
1179
  /**
1387
1180
  * Drag-to-place flow for palette templates. Adds the shape to the
1388
1181
  * scene immediately so the user sees it dragging under the cursor,
@@ -1393,7 +1186,6 @@ export class Editor {
1393
1186
  * Typical wiring: HTML5 dragenter starts the placement, dragover
1394
1187
  * updates, drop commits, dragleave / window keydown(Escape) cancel.
1395
1188
  */
1396
- // Placement helpers live in `./editor/public/placement.ts`.
1397
1189
  // Editor owns the transaction lifecycle and selection mutate;
1398
1190
  // the closure threads scene mutations through the pure helpers.
1399
1191
  beginPlacement(shape) {
@@ -1437,7 +1229,6 @@ export class Editor {
1437
1229
  },
1438
1230
  };
1439
1231
  }
1440
- // Pure body in `./editor/public/selection-ops.ts`.
1441
1232
  deleteSelected() {
1442
1233
  const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
1443
1234
  if (!result)
@@ -1498,8 +1289,9 @@ export class Editor {
1498
1289
  _textSel = null;
1499
1290
  /** Anchor offset for a canvas drag-select inside the edited text. */
1500
1291
  _textDragAnchor = null;
1501
- _caretBlinkOn = true;
1502
- _caretBlinkTimer = null;
1292
+ caretBlink = new CaretBlinkController(() => {
1293
+ this.notify();
1294
+ });
1503
1295
  get editingTextSelection() {
1504
1296
  return this._textSel;
1505
1297
  }
@@ -1510,41 +1302,17 @@ export class Editor {
1510
1302
  return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
1511
1303
  }
1512
1304
  get caretBlinkOn() {
1513
- return this._caretBlinkOn;
1305
+ return this.caretBlink.on;
1514
1306
  }
1515
1307
  /** `true` while a canvas drag-select inside the edited text is active. */
1516
1308
  get isTextDragging() {
1517
1309
  return this._textDragAnchor !== null;
1518
1310
  }
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
1311
  /**
1543
1312
  * Begin editing a text shape's body. No-op when the shape doesn't
1544
1313
  * exist or isn't a text shape. Concurrent edits commit themselves
1545
1314
  * (only one shape at a time). Caret defaults to the end of the text.
1546
1315
  */
1547
- // Pure bodies in `./editor/public/text-edit.ts`.
1548
1316
  /** Open inline caption editing for a link (double-click). */
1549
1317
  beginLinkCaptionEdit(id) {
1550
1318
  if (!getLink(this._scene, id))
@@ -1604,12 +1372,12 @@ export class Editor {
1604
1372
  const t = edge.label?.position ?? 0.5;
1605
1373
  let total = 0;
1606
1374
  for (let i = 1; i < path.length; i++)
1607
- total += distanceTo(req(path[i - 1]), req(path[i]));
1375
+ total += vec2.distance(req(path[i - 1]), req(path[i]));
1608
1376
  let remaining = total * t;
1609
1377
  for (let i = 1; i < path.length; i++) {
1610
1378
  const a = req(path[i - 1]);
1611
1379
  const b = req(path[i]);
1612
- const seg = distanceTo(a, b);
1380
+ const seg = vec2.distance(a, b);
1613
1381
  if (remaining <= seg) {
1614
1382
  const r = seg === 0 ? 0 : remaining / seg;
1615
1383
  return { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r };
@@ -1630,7 +1398,7 @@ export class Editor {
1630
1398
  const shape = getElement(this._scene, id);
1631
1399
  const len = shape?.text.length ?? 0;
1632
1400
  this._textSel = { start: len, end: len, dir: "forward" };
1633
- this.startCaretBlink();
1401
+ this.caretBlink.start();
1634
1402
  this.notify();
1635
1403
  }
1636
1404
  // --- Frame name inline editing (double-click the header) ---
@@ -1640,7 +1408,7 @@ export class Editor {
1640
1408
  */
1641
1409
  beginFrameNameEdit(id) {
1642
1410
  const shape = getElement(this._scene, id);
1643
- if (shape?.type !== "frame")
1411
+ if (shape === undefined || !isFrame(shape))
1644
1412
  return;
1645
1413
  if (this.isLayerLocked(shape.layerId))
1646
1414
  return;
@@ -1659,23 +1427,10 @@ export class Editor {
1659
1427
  if (id === null)
1660
1428
  return;
1661
1429
  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
- }
1430
+ const r = computeFrameNameCommit(this._scene, id, name);
1431
+ if (r) {
1432
+ this._scene = r.scene;
1433
+ this._history.push(r.patch);
1679
1434
  }
1680
1435
  this.notify();
1681
1436
  }
@@ -1693,25 +1448,7 @@ export class Editor {
1693
1448
  * hit-test bounds. Assumes unrotated frames (the common case).
1694
1449
  */
1695
1450
  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;
1451
+ return computeFrameHeaderAt(this._scene, p);
1715
1452
  }
1716
1453
  /**
1717
1454
  * Live edit transport from the hidden `<textarea>`: replace the
@@ -1726,7 +1463,7 @@ export class Editor {
1726
1463
  const r = updateElement(this._scene, id, (s) => ({ ...s, text: value }));
1727
1464
  this._scene = r.scene;
1728
1465
  this._textSel = { start: selStart, end: selEnd, dir };
1729
- this.wakeCaret();
1466
+ this.caretBlink.wake();
1730
1467
  this.notify();
1731
1468
  }
1732
1469
  /** Selection-only update (arrows / shift-select / click) — no text change. */
@@ -1734,7 +1471,7 @@ export class Editor {
1734
1471
  if (!this._editingTextElement)
1735
1472
  return;
1736
1473
  this._textSel = { start: selStart, end: selEnd, dir };
1737
- this.wakeCaret();
1474
+ this.caretBlink.wake();
1738
1475
  this.notify();
1739
1476
  }
1740
1477
  /**
@@ -1752,9 +1489,15 @@ export class Editor {
1752
1489
  const layout = this.editingTextLayout(shape);
1753
1490
  if (!layout)
1754
1491
  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 };
1492
+ // World → shape-local: undo the element transform so the hit lands on the
1493
+ // right glyph. Translate by position, then divide out scale (rotation
1494
+ // while editing text is not handled an uncommon case).
1495
+ const sx = shape.scale.x || 1;
1496
+ const sy = shape.scale.y || 1;
1497
+ const local = {
1498
+ x: (worldPoint.x - shape.position.x) / sx,
1499
+ y: (worldPoint.y - shape.position.y) / sy,
1500
+ };
1758
1501
  const align = shape.style.textAlign ?? "left";
1759
1502
  return pointToCaretIndex(layout, local, this.measureFor(shape), align);
1760
1503
  }
@@ -1842,18 +1585,24 @@ export class Editor {
1842
1585
  const align = shape.style.textAlign ?? "left";
1843
1586
  const measure = this.measureFor(shape);
1844
1587
  const { x: px, y: py } = shape.position;
1588
+ // The layout is in the shape's own (unscaled) space; the renderer draws it
1589
+ // through the element transform, so caret + selection geometry must scale
1590
+ // too or they trail the rendered text on a scaled element. (Rotation while
1591
+ // editing text is not handled — an uncommon case.)
1592
+ const sx = shape.scale.x;
1593
+ const sy = shape.scale.y;
1845
1594
  const local = textSelectionRects(layout, this._textSel.start, this._textSel.end, measure, align);
1846
1595
  const selectionRects = local.map((r) => ({
1847
- x: px + r.x,
1848
- y: py + r.y,
1849
- width: r.width,
1850
- height: r.height,
1596
+ x: px + Math.min(r.x * sx, (r.x + r.width) * sx),
1597
+ y: py + Math.min(r.y * sy, (r.y + r.height) * sy),
1598
+ width: Math.abs(r.width * sx),
1599
+ height: Math.abs(r.height * sy),
1851
1600
  }));
1852
1601
  let caret = null;
1853
- if (this._caretBlinkOn) {
1602
+ if (this.caretBlink.on) {
1854
1603
  const cIdx = this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
1855
1604
  const g = caretGeometry(layout, cIdx, measure, shape.fontSize, align);
1856
- caret = { x: px + g.x, y: py + g.y, height: g.height };
1605
+ caret = { x: px + g.x * sx, y: py + g.y * sy, height: g.height * Math.abs(sy) };
1857
1606
  }
1858
1607
  return { caret, caretColor: shape.style.fill ?? "#1a1a1a", selectionRects };
1859
1608
  }
@@ -1872,7 +1621,7 @@ export class Editor {
1872
1621
  this._pendingTextCreate = null;
1873
1622
  this._textEditOrigin = null;
1874
1623
  this._textSel = null;
1875
- this.stopCaretBlink();
1624
+ this.caretBlink.stop();
1876
1625
  const finalElement = getElement(this._scene, id);
1877
1626
  const text = finalElement?.text ?? "";
1878
1627
  // Empty (whitespace-only) text removes the shape. Pending = silent
@@ -1917,7 +1666,7 @@ export class Editor {
1917
1666
  this._pendingTextCreate = null;
1918
1667
  this._textEditOrigin = null;
1919
1668
  this._textSel = null;
1920
- this.stopCaretBlink();
1669
+ this.caretBlink.stop();
1921
1670
  // Revert live edits with no history entry. Pending creations are
1922
1671
  // removed entirely; existing shapes have only their TEXT restored
1923
1672
  // (panel-driven field changes during the edit keep their own
@@ -1941,7 +1690,6 @@ export class Editor {
1941
1690
  * keyboard navigation; hosts pass `{ x: 1, y: 0 }` for fine nudge
1942
1691
  * and `{ x: 10, y: 0 }` for shift-arrow.
1943
1692
  */
1944
- // Pure body in `./editor/public/selection-ops.ts`.
1945
1693
  moveSelectionBy(delta) {
1946
1694
  if (this._selection.size === 0 && this._selectedLinks.size === 0)
1947
1695
  return;
@@ -1980,7 +1728,6 @@ export class Editor {
1980
1728
  * Hosts can bind this to "Enter" while in a draw mode, providing a
1981
1729
  * mouse-free alternative to drag-out creation.
1982
1730
  */
1983
- // Pure body in `./editor/public/placement.ts`.
1984
1731
  createElementAtCursor() {
1985
1732
  const vp = this._scene.viewport;
1986
1733
  const world = this.screenToWorld({
@@ -2020,7 +1767,6 @@ export class Editor {
2020
1767
  this.beginTextEdit(id);
2021
1768
  return id;
2022
1769
  }
2023
- // Pure bodies in `./editor/public/brush.ts`.
2024
1770
  beginBrushStroke(world, pressure = 0.5) {
2025
1771
  this.brushStroke = beginBrushStrokePure(world, pressure);
2026
1772
  this.notify();
@@ -2054,7 +1800,6 @@ export class Editor {
2054
1800
  get pendingBrushStroke() {
2055
1801
  return this.brushStroke;
2056
1802
  }
2057
- // Pure bodies in `./editor/public/arrange-group.ts`.
2058
1803
  arrangeAsGrid(opts = {}) {
2059
1804
  const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
2060
1805
  const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
@@ -2156,7 +1901,6 @@ export class Editor {
2156
1901
  * Duplicate the selected shapes 10 px down-right of the originals.
2157
1902
  * Links between selected shapes are NOT cloned. Single undo step.
2158
1903
  */
2159
- // Pure body in `./editor/public/selection-ops.ts`.
2160
1904
  duplicateSelected() {
2161
1905
  const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
2162
1906
  if (!result)
@@ -2199,32 +1943,11 @@ export class Editor {
2199
1943
  const idMap = new Map();
2200
1944
  for (const id of ids)
2201
1945
  idMap.set(id, castElementId(this.uniqueId("shape")));
1946
+ const dup = computeDuplicateInPlace(this._scene, ids, idMap);
2202
1947
  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
- }
1948
+ this._scene = dup.scene;
1949
+ for (const patch of dup.patches)
1950
+ tx.add(patch);
2228
1951
  tx.commit();
2229
1952
  // Select the clones of the originally-selected ids.
2230
1953
  const selectedClones = [];
@@ -2269,7 +1992,6 @@ export class Editor {
2269
1992
  * `navigator.clipboard` (out of scope for the editor).
2270
1993
  */
2271
1994
  clipboard = [];
2272
- // Pure body in `./editor/public/clipboard.ts`.
2273
1995
  copySelected() {
2274
1996
  const out = copySelectedPure(this._scene, this._selection);
2275
1997
  if (out.length === 0)
@@ -2290,7 +2012,6 @@ export class Editor {
2290
2012
  *
2291
2013
  * New shapes get fresh ids and end up selected. Single undo step.
2292
2014
  */
2293
- // Pure body in `./editor/public/clipboard.ts`.
2294
2015
  paste(targetWorld) {
2295
2016
  if (this.clipboard.length === 0)
2296
2017
  return;
@@ -2315,7 +2036,30 @@ export class Editor {
2315
2036
  *
2316
2037
  * No-op when `ids` is empty or none of the targeted shapes exist.
2317
2038
  */
2318
- // Pure body in `./editor/public/selection-ops.ts`.
2039
+ /**
2040
+ * Capture the visual style of the first selected element into the style
2041
+ * buffer, for a later {@link pasteSelectionStyle}. No-op / clears nothing
2042
+ * when the selection is empty.
2043
+ */
2044
+ copySelectionStyle() {
2045
+ for (const id of this._selection) {
2046
+ const el = getElement(this._scene, id);
2047
+ if (el !== undefined) {
2048
+ this.styleClipboard = { ...el.style };
2049
+ return;
2050
+ }
2051
+ }
2052
+ }
2053
+ /** Apply the copied style (if any) to every selected element. One undo step. */
2054
+ pasteSelectionStyle() {
2055
+ if (this.styleClipboard === null)
2056
+ return;
2057
+ this.updateStyle(this._selection, this.styleClipboard);
2058
+ }
2059
+ /** Whether a style has been copied and can be pasted. */
2060
+ get hasStyleClipboard() {
2061
+ return this.styleClipboard !== null;
2062
+ }
2319
2063
  updateStyle(ids, partial) {
2320
2064
  const result = computeUpdateStyle(this._scene, ids, partial);
2321
2065
  if (!result)
@@ -2337,6 +2081,19 @@ export class Editor {
2337
2081
  this._history.push(result.patch);
2338
2082
  this.notify();
2339
2083
  }
2084
+ /**
2085
+ * Step the font size of every selected text shape up or down by one gentle
2086
+ * multiplicative increment (each shape relative to its own size). One
2087
+ * undoable step; no-op when no text is selected.
2088
+ */
2089
+ adjustSelectionFontSize(direction) {
2090
+ const result = computeAdjustFontSize(this._scene, this._selection, direction);
2091
+ if (!result)
2092
+ return;
2093
+ this._scene = result.scene;
2094
+ this._history.push(result.patch);
2095
+ this.notify();
2096
+ }
2340
2097
  /**
2341
2098
  * Set (or clear, with `null`) the element-level hyperlink (`href`) on
2342
2099
  * every shape in `ids`. Single undo step. Pass a raw user string — it
@@ -2383,7 +2140,6 @@ export class Editor {
2383
2140
  return null;
2384
2141
  return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
2385
2142
  }
2386
- // Pure bodies in `./editor/public/z-order.ts`.
2387
2143
  bringToFront(id) {
2388
2144
  const result = computeBringToFront(this._scene, id, this._selection);
2389
2145
  if (!result)
@@ -2418,6 +2174,62 @@ export class Editor {
2418
2174
  this._history.push(result.patch);
2419
2175
  this.notify();
2420
2176
  }
2177
+ /**
2178
+ * Mirror the current selection about its bounding-box centre on the given
2179
+ * axis — `horizontal` flips left↔right, `vertical` flips top↔bottom. A single
2180
+ * element flips about its own centre. One undoable step.
2181
+ */
2182
+ flipSelection(axis) {
2183
+ this.commitArrange(computeFlipPatches(this._scene, this._selection, axis));
2184
+ }
2185
+ /**
2186
+ * Align the selection to the given edge / centre line of its bounding box
2187
+ * (e.g. `left` flushes every shape's left edge; `h-center` lines up
2188
+ * horizontal centres). Needs two or more elements. One undoable step.
2189
+ */
2190
+ alignSelection(edge) {
2191
+ this.commitArrange(computeAlignPatches(this._scene, this._selection, edge));
2192
+ }
2193
+ /**
2194
+ * Evenly space the selection along the given axis so the gaps between
2195
+ * adjacent shapes are equal; the outermost shapes stay put. Needs three or
2196
+ * more elements. One undoable step.
2197
+ */
2198
+ distributeSelection(axis) {
2199
+ this.commitArrange(computeDistributePatches(this._scene, this._selection, axis));
2200
+ }
2201
+ /**
2202
+ * Rotate the whole selection by `delta` radians about its bounding-box centre
2203
+ * (a single shape turns about its own centre). One undoable step. The live
2204
+ * rotate gesture drives the same maths from a press-time snapshot.
2205
+ */
2206
+ rotateSelection(delta) {
2207
+ const elements = [];
2208
+ const origin = new Map();
2209
+ for (const id of this._selection) {
2210
+ const el = getElement(this._scene, id);
2211
+ if (el === undefined)
2212
+ continue;
2213
+ elements.push(el);
2214
+ origin.set(id, { position: el.position, rotation: el.rotation });
2215
+ }
2216
+ if (elements.length === 0)
2217
+ return;
2218
+ const pivot = selectionCenter(elements);
2219
+ this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
2220
+ }
2221
+ /** Apply a batch of arrange patches as a single undoable step. */
2222
+ commitArrange(patches) {
2223
+ if (patches.length === 0)
2224
+ return;
2225
+ const tx = this._history.transaction();
2226
+ for (const patch of patches) {
2227
+ this._scene = apply(this._scene, patch);
2228
+ tx.add(patch);
2229
+ }
2230
+ tx.commit();
2231
+ this.notify();
2232
+ }
2421
2233
  compactLayerZOrder(layerId, options = {}) {
2422
2234
  const recordHistory = options.recordHistory ?? true;
2423
2235
  const layerIds = layerId ? [layerId] : [...this._scene.layers.keys()];
@@ -2468,7 +2280,6 @@ export class Editor {
2468
2280
  this._activeLayerId = id;
2469
2281
  this.notify();
2470
2282
  }
2471
- // Pure bodies in `./editor/public/layers.ts`.
2472
2283
  createLayer(name) {
2473
2284
  const result = computeCreateLayer(this._scene, name, newLayerId(++this.nextId));
2474
2285
  this._scene = result.scene;
@@ -2532,7 +2343,6 @@ export class Editor {
2532
2343
  * x → shapes move right relative to the user). Not recorded in
2533
2344
  * history — viewport state is editor-local.
2534
2345
  */
2535
- // Pure bodies in `./editor/public/zoom-pan.ts`.
2536
2346
  panBy(deltaScreen) {
2537
2347
  const next = computePan(this._scene, deltaScreen);
2538
2348
  if (!next)
@@ -2588,43 +2398,13 @@ export class Editor {
2588
2398
  selectClosest(direction) {
2589
2399
  const ref = this.combinedSelectionBounds();
2590
2400
  const vp = this._scene.viewport;
2591
- const refC = ref
2401
+ const refCenter = ref
2592
2402
  ? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
2593
2403
  : {
2594
2404
  x: vp.pan.x + vp.size.width / 2 / vp.zoom,
2595
2405
  y: vp.pan.y + vp.size.height / 2 / vp.zoom,
2596
2406
  };
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
- }
2407
+ const best = findClosestInDirection(this._scene, this._selection, direction, refCenter, (s) => this.isElementInteractable(s));
2628
2408
  if (best === null)
2629
2409
  return;
2630
2410
  this.setSelection([best]);
@@ -2667,17 +2447,28 @@ export class Editor {
2667
2447
  this.snapSuppressed = suppressed;
2668
2448
  }
2669
2449
  /**
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.
2450
+ * Host hook: mirror the Alt / Shift modifier state so an in-flight resize or
2451
+ * move reacts to it `alt` resizes about the centre, `shift` locks the
2452
+ * resize aspect ratio (or constrains a move to one axis). The app wires
2453
+ * keydown/keyup of the modifiers to this. Idempotent; never touches history.
2454
+ */
2455
+ setTransformModifiers(mods) {
2456
+ this.transformAltKey = mods.alt;
2457
+ this.transformShiftKey = mods.shift;
2458
+ }
2459
+ /**
2460
+ * True when a gesture should snap. Snapping is coupled to grid display:
2461
+ * it is active only while the grid is enabled (`gridEnabled`) — snapping to
2462
+ * a hidden grid is confusing. `snapToGrid` is an extra programmatic opt-out;
2463
+ * the suppress modifier (Cmd/Ctrl) bypasses snapping for the current gesture.
2674
2464
  */
2675
2465
  snapActive() {
2676
- return !this.snapSuppressed && this.gridVisible && isSnapToGridEnabled(this._scene.viewport);
2466
+ const viewport = this._scene.viewport;
2467
+ return !this.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport);
2677
2468
  }
2678
2469
  /** World-unit spacing the current gesture snaps to. */
2679
2470
  snapSpacing() {
2680
- return resolveSnapSpacing(this._scene.viewport);
2471
+ return resolveSnapSpacing();
2681
2472
  }
2682
2473
  /**
2683
2474
  * Replace the entire scene (e.g. after `parseScene`). Clears history,
@@ -2708,7 +2499,7 @@ export class Editor {
2708
2499
  }
2709
2500
  // Restore transient animationData (GIF bytes) from Scene.files
2710
2501
  // before the tick so the animation adapter can decode frames.
2711
- this.rehydrateAnimatedImages();
2502
+ animScene.rehydrateAnimatedImages(this);
2712
2503
  this.notify();
2713
2504
  // Loaded scene may carry animated shapes (e.g. GIF re-imported
2714
2505
  // from saved JSON). Re-arm the tick — `metadata.animated` survives
@@ -2726,20 +2517,14 @@ export class Editor {
2726
2517
  this.cursorListeners.clear();
2727
2518
  this.longPressListeners.clear();
2728
2519
  this.announceListeners.clear();
2729
- this.animationTick.stop();
2520
+ this.animation.detach();
2730
2521
  this.animationContentOff?.();
2731
- if (typeof document !== "undefined") {
2732
- document.removeEventListener("visibilitychange", this.onVisibilityChange);
2733
- }
2734
2522
  if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
2735
2523
  cancelAnimationFrame(this.renderRafId);
2736
2524
  this.renderRafId = null;
2737
2525
  }
2738
2526
  }
2739
2527
  // --- 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
2528
  bindPointerEvents() {
2744
2529
  return bindPointerEventsExternal(this);
2745
2530
  }
@@ -2795,14 +2580,14 @@ export class Editor {
2795
2580
  isDrawingPhase(ctx) {
2796
2581
  return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
2797
2582
  }
2798
- // --- Long-press --- (controller in `./editor/long-press.ts`)
2583
+ // --- Long-press ---
2799
2584
  startLongPress(screenPoint) {
2800
2585
  this.longPress.start(screenPoint);
2801
2586
  }
2802
2587
  cancelLongPress() {
2803
2588
  this.longPress.cancel();
2804
2589
  }
2805
- // --- Pinch gesture --- (controller in `./editor/pinch.ts`)
2590
+ // --- Pinch gesture ---
2806
2591
  beginPinch() {
2807
2592
  this.pinch.begin([...this.activePointers.values()]);
2808
2593
  }
@@ -2817,9 +2602,9 @@ export class Editor {
2817
2602
  screenToWorld(point) {
2818
2603
  return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
2819
2604
  }
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, …).
2605
+ // Editor passes a narrow context bundle that closes over its
2606
+ // private state + accel helpers (acceleratedElementAt,
2607
+ // isElementInteractable, …).
2823
2608
  /**
2824
2609
  * Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
2825
2610
  * interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
@@ -2862,87 +2647,10 @@ export class Editor {
2862
2647
  * out of sync. `worldPoint` defaults to the last known pointer position.
2863
2648
  */
2864
2649
  refreshCursor(worldPoint) {
2865
- const next = this.computeCursor(worldPoint ?? this.lastPointerWorld);
2650
+ const next = computeCursor(this, worldPoint ?? this.lastPointerWorld);
2866
2651
  if (this.host.style.cursor !== next)
2867
2652
  this.host.style.cursor = next;
2868
2653
  }
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
2654
  /**
2947
2655
  * Register (or clear, with `null`) a custom cursor image for a state role.
2948
2656
  * The image is shown wherever `computeCursor` resolves that role; pass a
@@ -2958,31 +2666,12 @@ export class Editor {
2958
2666
  this.refreshCursor();
2959
2667
  }
2960
2668
  /**
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.
2669
+ * Read-only lookup of a host-registered cursor override. Used by the cursor
2670
+ * module to resolve a role without exposing the mutable override map (mutate
2671
+ * only via {@link setCursorOverride}, so `refreshCursor` stays in sync).
2965
2672
  */
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;
2673
+ getCursorOverride(role) {
2674
+ return this.cursorOverrides.get(role);
2986
2675
  }
2987
2676
  /** True when the given layer exists and is marked `locked`. */
2988
2677
  isLayerLocked(layerId) {
@@ -3070,8 +2759,7 @@ export class Editor {
3070
2759
  * Topmost group ancestor of `shape` (walks parentId chain, returns
3071
2760
  * the highest `type === "group"` parent). `null` if `shape` has no
3072
2761
  * 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`.
2762
+ * with a group ancestor enters that group.
3075
2763
  */
3076
2764
  topGroupAncestor(shape) {
3077
2765
  return topGroupAncestorHelper(this._scene, shape);
@@ -3098,7 +2786,6 @@ export class Editor {
3098
2786
  * practice, but the guard keeps the contract simple — "what you've
3099
2787
  * selected, you can see".
3100
2788
  */
3101
- // Body moved to `./editor/shape-filters.ts`.
3102
2789
  computeHiddenElements() {
3103
2790
  return computeHiddenElementsPure(this._scene);
3104
2791
  }
@@ -3128,7 +2815,7 @@ export class Editor {
3128
2815
  const el = getElement(this._scene, id);
3129
2816
  if (!el)
3130
2817
  return;
3131
- const isFrameEl = el.type === "frame";
2818
+ const isFrameEl = isFrame(el);
3132
2819
  const members = [];
3133
2820
  for (const s of this._scene.elements.values()) {
3134
2821
  if (s.parentId === id || (isFrameEl && s.frameId === id))
@@ -3136,7 +2823,7 @@ export class Editor {
3136
2823
  }
3137
2824
  if (members.length === 0)
3138
2825
  return;
3139
- if (el.type === "group")
2826
+ if (isGroup(el))
3140
2827
  this._enteredGroup = id;
3141
2828
  this.setSelection(members);
3142
2829
  }
@@ -3230,7 +2917,7 @@ export class Editor {
3230
2917
  const now = performance.now();
3231
2918
  const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
3232
2919
  this.lastClickWorldPoint !== null &&
3233
- distanceTo(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
2920
+ vec2.distance(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
3234
2921
  this.lastClickAt = now;
3235
2922
  this.lastClickWorldPoint = worldPoint;
3236
2923
  // Double-click the frame HEADER (label strip above the body) → rename.
@@ -3277,11 +2964,11 @@ export class Editor {
3277
2964
  if (isDouble &&
3278
2965
  (clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
3279
2966
  const raw = this.acceleratedElementAt(worldPoint);
3280
- if (raw?.type === "text") {
2967
+ if (raw !== undefined && isText(raw)) {
3281
2968
  this.beginTextEdit(raw.id);
3282
2969
  return true;
3283
2970
  }
3284
- if (raw?.type === "frame") {
2971
+ if (raw !== undefined && isFrame(raw)) {
3285
2972
  this.beginFrameNameEdit(raw.id);
3286
2973
  return true;
3287
2974
  }
@@ -3396,6 +3083,9 @@ export class Editor {
3396
3083
  case "RESIZE_SHAPE":
3397
3084
  this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
3398
3085
  return;
3086
+ case "ROTATE":
3087
+ this.applyRotate(emit.deltaAngle);
3088
+ return;
3399
3089
  case "CREATE_SHAPE":
3400
3090
  this.applyCreate(emit.shapeType, emit.bounds);
3401
3091
  return;
@@ -3442,7 +3132,6 @@ export class Editor {
3442
3132
  * Wrapped in a single gestureTx so per-move updates collapse
3443
3133
  * into one undo step.
3444
3134
  */
3445
- // Pure body in `./editor/applies/move.ts`.
3446
3135
  applyAnnotationMove(id, delta, origin) {
3447
3136
  const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
3448
3137
  if (!result)
@@ -3478,13 +3167,14 @@ export class Editor {
3478
3167
  dispatchTemplateDrop(emit) {
3479
3168
  this.applyEmit(emit);
3480
3169
  }
3481
- // Pure body in `./editor/applies/move.ts`.
3482
3170
  applyMove(id, delta, originalBounds) {
3483
3171
  // Locked / layer-locked elements are selectable but don't move.
3484
3172
  const el = getElement(this._scene, id);
3485
3173
  if (el && !this.isElementManipulable(el))
3486
3174
  return;
3487
- const d = this.snapActive() ? snapMoveDelta(originalBounds, delta, this.snapSpacing()) : delta;
3175
+ // Shift constrains the drag to one axis before snapping.
3176
+ const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
3177
+ const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
3488
3178
  const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
3489
3179
  if (!patch)
3490
3180
  return;
@@ -3495,9 +3185,11 @@ export class Editor {
3495
3185
  applyGroupMove(delta) {
3496
3186
  if (!this.groupMoveOrigin)
3497
3187
  return;
3188
+ // Shift constrains the drag to one axis before snapping.
3189
+ const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
3498
3190
  const d = this.snapActive()
3499
- ? snapGroupDelta(this.groupMoveOrigin, delta, this.snapSpacing())
3500
- : delta;
3191
+ ? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
3192
+ : moved;
3501
3193
  const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
3502
3194
  for (const patch of patches) {
3503
3195
  this._scene = apply(this._scene, patch);
@@ -3515,7 +3207,6 @@ export class Editor {
3515
3207
  }
3516
3208
  this.notify();
3517
3209
  }
3518
- // Body moved to `./editor/viewport-helpers.ts`.
3519
3210
  computeViewportWorld() {
3520
3211
  return computeViewportWorldPure(this._scene);
3521
3212
  }
@@ -3584,125 +3275,16 @@ export class Editor {
3584
3275
  // repaint when the entered-group identity changes between frames.
3585
3276
  if (this.lastRenderedEnteredGroup !== this._enteredGroup)
3586
3277
  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);
3278
+ // Diff the two scenes for the dirty rect + tile-cache invalidation. The
3279
+ // state-coupled guards above stay here; the pure scene diff lives in
3280
+ // `computeSceneDirtyRect`.
3281
+ const { world, tileDirty } = computeSceneDirtyRect(prev, next);
3282
+ if (this.tileComposeFn !== null) {
3283
+ for (const [id, entry] of tileDirty)
3284
+ this.tileDirtyElements.set(id, entry);
3656
3285
  }
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);
3286
+ return world;
3704
3287
  }
3705
- // Bodies moved to `./editor/viewport-helpers.ts`.
3706
3288
  combinedSelectionBounds() {
3707
3289
  let acc = combinedSelectionBoundsPure(this._scene, this._selection);
3708
3290
  // Selected links join the selection box (standard parity) — union in
@@ -3758,19 +3340,42 @@ export class Editor {
3758
3340
  // Multi-selection: lock when every selected shape is an image — they
3759
3341
  // must never be stretched out of ratio, only scaled together.
3760
3342
  for (const id of this._selection) {
3761
- if (getElement(this._scene, id)?.type !== "image")
3343
+ const el = getElement(this._scene, id);
3344
+ if (el === undefined || !isImage(el))
3762
3345
  return false;
3763
3346
  }
3764
3347
  return true;
3765
3348
  }
3766
- // Pure body in `./editor/applies/resize.ts`.
3349
+ /**
3350
+ * Per-frame rotate during a grip drag: turn the press-time snapshot by
3351
+ * `deltaAngle` about its pivot. Shift snaps the swept angle to
3352
+ * {@link ROTATE_SNAP_RADIANS} steps. Recorded as gesture patches (one undo
3353
+ * step on commit).
3354
+ */
3355
+ applyRotate(deltaAngle) {
3356
+ const gesture = this.rotateGestureOrigin;
3357
+ if (!gesture)
3358
+ return;
3359
+ const d = this.transformShiftKey
3360
+ ? Math.round(deltaAngle / ROTATE_SNAP_RADIANS) * ROTATE_SNAP_RADIANS
3361
+ : deltaAngle;
3362
+ const patches = computeRotatePatches(this._scene, gesture.origin, gesture.pivot, d);
3363
+ for (const patch of patches) {
3364
+ this._scene = apply(this._scene, patch);
3365
+ this.recordGesturePatch(patch);
3366
+ }
3367
+ this.notify();
3368
+ }
3767
3369
  applyGroupResize(handle, delta, originalBounds) {
3768
3370
  if (!this.groupResizeOrigin)
3769
3371
  return;
3770
3372
  const d = this.snapActive()
3771
3373
  ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
3772
3374
  : delta;
3773
- const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds, this.selectionIsAspectLocked());
3375
+ const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
3376
+ // Aspect-locked when the selection type demands it (images / groups) or
3377
+ // the user holds Shift for this gesture.
3378
+ this.selectionIsAspectLocked() || this.transformShiftKey, this.transformAltKey);
3774
3379
  this._scene = result.scene;
3775
3380
  for (const patch of result.patches)
3776
3381
  this.recordGesturePatch(patch);
@@ -3778,16 +3383,32 @@ export class Editor {
3778
3383
  }
3779
3384
  applyResize(id, handle, delta, originalBounds) {
3780
3385
  const shape = getElement(this._scene, id);
3386
+ // Rotated shape: the axis-aligned world resize below would corrupt it
3387
+ // (handles live on the tilted box). Resize in the shape's own frame,
3388
+ // keeping the opposite corner fixed in world. Snapshot the pristine pose
3389
+ // on the first tick so the closed-form never compounds. Grid-snap is
3390
+ // skipped — snapping a tilted box to the world grid is ill-defined.
3391
+ if (shape !== undefined && !isText(shape) && shape.rotation !== 0) {
3392
+ if (this._resizeOriginElement?.id !== id)
3393
+ this._resizeOriginElement = shape;
3394
+ const result = computeRotatedElementResize(this._scene, this._resizeOriginElement, handle, delta, this.transformShiftKey, this.transformAltKey);
3395
+ if (!result)
3396
+ return;
3397
+ this._scene = result.scene;
3398
+ this.recordGesturePatch(result.patch);
3399
+ this.notify();
3400
+ return;
3401
+ }
3781
3402
  const d = this.snapActive()
3782
3403
  ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
3783
3404
  : delta;
3784
3405
  // Text: aspect-locked font scaling. Snapshot the pristine shape on
3785
3406
  // the gesture's first tick so the scale base never compounds.
3786
- if (shape?.type === "text") {
3407
+ if (shape !== undefined && isText(shape)) {
3787
3408
  if (this._resizeOriginElement?.id !== id) {
3788
3409
  this._resizeOriginElement = shape;
3789
3410
  }
3790
- const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds);
3411
+ const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds, this.transformAltKey);
3791
3412
  if (!result)
3792
3413
  return;
3793
3414
  this._scene = result.scene;
@@ -3795,14 +3416,13 @@ export class Editor {
3795
3416
  this.notify();
3796
3417
  return;
3797
3418
  }
3798
- const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h));
3419
+ const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.transformShiftKey, this.transformAltKey);
3799
3420
  if (!result)
3800
3421
  return;
3801
3422
  this._scene = result.scene;
3802
3423
  this.recordGesturePatch(result.patch);
3803
3424
  this.notify();
3804
3425
  }
3805
- // Pure body in `./editor/applies/create.ts`.
3806
3426
  applyCreate(kind, bounds) {
3807
3427
  const id = newElementId(++this.nextId);
3808
3428
  const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
@@ -3842,8 +3462,7 @@ export class Editor {
3842
3462
  reconcileFrameMembership() {
3843
3463
  this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
3844
3464
  }
3845
- // Pure body in `./editor/applies/create.ts`. Endpoint snapping
3846
- // stays here because it needs the snap engine.
3465
+ // Endpoint snapping stays here because it needs the snap engine.
3847
3466
  applyCreateLink(emit) {
3848
3467
  const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
3849
3468
  const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
@@ -3882,35 +3501,11 @@ export class Editor {
3882
3501
  return;
3883
3502
  }
3884
3503
  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
- };
3504
+ const r = computeShapeAtLinkDrop(this._scene, this._activeLayerId, pending, newId, factory);
3904
3505
  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);
3506
+ this._scene = r.scene;
3507
+ tx.add(r.addPatch);
3508
+ tx.add(r.linkPatch);
3914
3509
  tx.commit();
3915
3510
  this.pendingLinkDropMenu = null;
3916
3511
  this._selection = Selection.single(newId);
@@ -3932,50 +3527,15 @@ export class Editor {
3932
3527
  * selection. Element + link land in one undo step.
3933
3528
  */
3934
3529
  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
3530
  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
3531
  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);
3532
+ const r = computeLinkedElementFromAnchor(this._scene, this._activeLayerId, fromElement, anchorName, newId, linkId);
3533
+ if (!r)
3534
+ return;
3535
+ const tx = this._history.transaction();
3536
+ this._scene = r.scene;
3537
+ tx.add(r.addPatch);
3538
+ tx.add(r.linkPatch);
3979
3539
  tx.commit();
3980
3540
  this._selection = Selection.single(newId);
3981
3541
  if (this._selectedLinks.size > 0)
@@ -3989,72 +3549,7 @@ export class Editor {
3989
3549
  * `createLinkedElementFromAnchor`.
3990
3550
  */
3991
3551
  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
- };
3552
+ return previewClickCreatePure(this._scene, this._activeLayerId, fromElement, anchorName);
4058
3553
  }
4059
3554
  /**
4060
3555
  * Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
@@ -4068,61 +3563,10 @@ export class Editor {
4068
3563
  * shapes when the user clearly aimed for this one).
4069
3564
  */
4070
3565
  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 };
3566
+ return snapLinkEndpointPure(this._scene, this.snapEngine, this.snapThreshold, pressTargetElement, worldPoint);
4123
3567
  }
4124
- // Pure body in `./editor/applies/selection.ts`. The wrappers
4125
- // here own the side effects (`_selectedLink` clearing, notify).
3568
+ // The wrappers here own the side effects (`_selectedLink`
3569
+ // clearing, notify).
4126
3570
  applySelectByBounds(bounds, mode) {
4127
3571
  const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
4128
3572
  const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
@@ -4146,9 +3590,8 @@ export class Editor {
4146
3590
  this._selectedLinks = nextLinks;
4147
3591
  this._selection = next;
4148
3592
  }
4149
- // Pure body in `./editor/applies/edge.ts`. The wrapper here
4150
- // owns the side effects (history push, drag-state clearing,
4151
- // notify).
3593
+ // The wrapper here owns the side effects (history push,
3594
+ // drag-state clearing, notify).
4152
3595
  /**
4153
3596
  * Live endpoint-rebind move: re-point the dragged end to the cursor in the
4154
3597
  * scene (a free `point` endpoint), recorded in the gesture transaction so the
@@ -4272,7 +3715,7 @@ export class Editor {
4272
3715
  const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
4273
3716
  const a = path?.[drag.index];
4274
3717
  const b = path?.[drag.index + 2];
4275
- if (a && b && distanceToSegmentPt(wp, a, b) <= collapse) {
3718
+ if (a && b && hitTest.distanceToSegment(wp, a, b) <= collapse) {
4276
3719
  const wps = edge.waypoints.filter((_, i) => i !== drag.index);
4277
3720
  const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
4278
3721
  this._scene = r.scene;
@@ -4341,7 +3784,7 @@ export class Editor {
4341
3784
  const now = performance.now();
4342
3785
  const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
4343
3786
  this.lastHandleClickWorld !== null &&
4344
- distanceTo(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
3787
+ vec2.distance(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
4345
3788
  this.lastHandleClickAt = now;
4346
3789
  this.lastHandleClickWorld = world;
4347
3790
  return isDouble;
@@ -4469,7 +3912,6 @@ export class Editor {
4469
3912
  this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
4470
3913
  this.notify();
4471
3914
  }
4472
- // Pure body in `./editor/applies/edge.ts`.
4473
3915
  applyLinkPreview(fromElement, fromPoint, toPoint) {
4474
3916
  const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
4475
3917
  // Match the preview to the connector that will be committed: when new
@@ -4486,9 +3928,7 @@ export class Editor {
4486
3928
  this.notify();
4487
3929
  }
4488
3930
  // 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.
3931
+ // cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate.
4492
3932
  recordGesturePatch(patch) {
4493
3933
  // Snapshot the pre-gesture scene the moment the transaction opens, so a
4494
3934
  // later cancel/Escape can restore it (the history tx only records undo data,
@@ -4500,6 +3940,7 @@ export class Editor {
4500
3940
  }
4501
3941
  commitGesture() {
4502
3942
  this._resizeOriginElement = null;
3943
+ this.rotateGestureOrigin = null;
4503
3944
  this.gestures.commit();
4504
3945
  this.gestureStartScene = null;
4505
3946
  }
@@ -4520,9 +3961,9 @@ export class Editor {
4520
3961
  * - Cycles (a container inside its own descendant) are prevented by the
4521
3962
  * `containerHover` pipeline above — the exclude set rules them out.
4522
3963
  */
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.
3964
+ // Editor exposes a small `ContainerOpsRef` bridge so the pure
3965
+ // helper can mutate scene + push patches into the running gesture
3966
+ // transaction.
4526
3967
  applyContainerDrop(worldPoint) {
4527
3968
  applyContainerDropPure(this.containerOpsRef, worldPoint);
4528
3969
  }
@@ -4530,7 +3971,6 @@ export class Editor {
4530
3971
  maybeGrowContainer(containerId, childId) {
4531
3972
  maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
4532
3973
  }
4533
- // Pure body in `./editor/container-ops.ts`.
4534
3974
  clampContainerToChildren(shape, raw, handle) {
4535
3975
  return clampContainerToChildrenPure(this._scene, shape, raw, handle);
4536
3976
  }
@@ -4543,9 +3983,9 @@ export class Editor {
4543
3983
  this.gestureTx ??= this._history.transaction();
4544
3984
  return this.gestureTx;
4545
3985
  }
4546
- // Body moved to `./editor/gesture-tx.ts`.
4547
3986
  cancelGesture() {
4548
3987
  this._resizeOriginElement = null;
3988
+ this.rotateGestureOrigin = null;
4549
3989
  this.gestures.cancel();
4550
3990
  // Roll the scene back to the pre-gesture snapshot — cancelling the history
4551
3991
  // transaction alone leaves the live drag mutations in `_scene`.
@@ -4637,9 +4077,8 @@ export class Editor {
4637
4077
  /**
4638
4078
  * Typed event surface — subscribe to a specific slice (`mode`,
4639
4079
  * `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.
4080
+ * `change`. For callers that only care about one dimension;
4081
+ * `subscribe()` fires in lock-step with these.
4643
4082
  */
4644
4083
  on(event, fn) {
4645
4084
  // Cast through `never`: TS can't prove that EditorEvents[K]
@@ -4678,33 +4117,6 @@ export class Editor {
4678
4117
  * between frames the A* route is reused — see `rerouteElbows`.
4679
4118
  */
4680
4119
  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
4120
  /**
4709
4121
  * Choke-point reroute (standard model): recompute `routedPoints` for
4710
4122
  * every orthogonal link whose inputs changed since the last pass, and
@@ -4717,7 +4129,7 @@ export class Editor {
4717
4129
  for (const [id, edge] of this._scene.links) {
4718
4130
  if ((edge.routing ?? "straight") !== "orthogonal")
4719
4131
  continue;
4720
- const sig = this.elbowSignature(edge);
4132
+ const sig = elbowSignature(this._scene, edge);
4721
4133
  if (this.elbowRouteSig.get(id) === sig)
4722
4134
  continue;
4723
4135
  this.elbowRouteSig.set(id, sig);
@@ -4726,32 +4138,19 @@ export class Editor {
4726
4138
  }
4727
4139
  this._scene = next;
4728
4140
  }
4729
- // Pure body in `./editor/render-orchestrator.ts` (~130 lines).
4730
4141
  render() {
4731
4142
  this.rerouteElbows();
4732
4143
  // Feed the renderer's animation clock our per-shape playback state
4733
4144
  // so paused / reduced-motion GIFs freeze and resumed ones continue
4734
4145
  // from the right frame. Set immediately before the synchronous
4735
4146
  // render pass (the shape-renderer has no options channel).
4736
- setAnimationClock((shape) => this.playbackClock(shape.id));
4147
+ setAnimationClock((shape) => this.gifPlayback.clock(shape.id));
4737
4148
  renderEditor(this);
4738
4149
  // Present AFTER the paint, on the same tick — deferred-submission
4739
4150
  // surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
4740
4151
  this.onAfterRender?.();
4741
4152
  }
4742
4153
  }
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
4154
  /**
4756
4155
  * Type guard — `true` when the value already implements the
4757
4156
  * `HistoryProvider` surface. Used to decide between "host supplied
@@ -4767,37 +4166,5 @@ const isHistoryProvider = (value) => {
4767
4166
  typeof value.redo === "function" &&
4768
4167
  typeof value.transaction === "function");
4769
4168
  };
4770
- // `describeNudge` moved to `./editor/public/selection-ops.ts`.
4771
4169
  /** 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
4170
  //# sourceMappingURL=editor.js.map