@oh-just-another/state 0.58.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 (194) hide show
  1. package/CHANGELOG.md +67 -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 +27 -1
  11. package/dist/actions/actionClipboard.js.map +1 -1
  12. package/dist/actions/actionText.d.ts +5 -0
  13. package/dist/actions/actionText.d.ts.map +1 -0
  14. package/dist/actions/actionText.js +37 -0
  15. package/dist/actions/actionText.js.map +1 -0
  16. package/dist/actions/actionView.js +1 -1
  17. package/dist/actions/index.d.ts +3 -1
  18. package/dist/actions/index.d.ts.map +1 -1
  19. package/dist/actions/index.js +3 -1
  20. package/dist/actions/index.js.map +1 -1
  21. package/dist/actions/registry.d.ts.map +1 -1
  22. package/dist/actions/registry.js +4 -0
  23. package/dist/actions/registry.js.map +1 -1
  24. package/dist/actions/types.d.ts +1 -1
  25. package/dist/actions/types.d.ts.map +1 -1
  26. package/dist/actions/types.js.map +1 -1
  27. package/dist/auto-layout-scheduler.d.ts +1 -3
  28. package/dist/auto-layout-scheduler.d.ts.map +1 -1
  29. package/dist/auto-layout-scheduler.js +1 -2
  30. package/dist/auto-layout-scheduler.js.map +1 -1
  31. package/dist/built-in-handlers.d.ts.map +1 -1
  32. package/dist/built-in-handlers.js +14 -1
  33. package/dist/built-in-handlers.js.map +1 -1
  34. package/dist/constants.d.ts +48 -23
  35. package/dist/constants.d.ts.map +1 -1
  36. package/dist/constants.js +48 -23
  37. package/dist/constants.js.map +1 -1
  38. package/dist/dom-events.d.ts +7 -0
  39. package/dist/dom-events.d.ts.map +1 -1
  40. package/dist/dom-events.js +12 -0
  41. package/dist/dom-events.js.map +1 -1
  42. package/dist/editor/animation-scene.d.ts +21 -0
  43. package/dist/editor/animation-scene.d.ts.map +1 -0
  44. package/dist/editor/animation-scene.js +71 -0
  45. package/dist/editor/animation-scene.js.map +1 -0
  46. package/dist/editor/animation.d.ts +43 -0
  47. package/dist/editor/animation.d.ts.map +1 -0
  48. package/dist/editor/animation.js +80 -0
  49. package/dist/editor/animation.js.map +1 -0
  50. package/dist/editor/applies/arrange.d.ts +46 -0
  51. package/dist/editor/applies/arrange.d.ts.map +1 -0
  52. package/dist/editor/applies/arrange.js +162 -0
  53. package/dist/editor/applies/arrange.js.map +1 -0
  54. package/dist/editor/applies/edge.d.ts +8 -1
  55. package/dist/editor/applies/edge.d.ts.map +1 -1
  56. package/dist/editor/applies/edge.js +31 -1
  57. package/dist/editor/applies/edge.js.map +1 -1
  58. package/dist/editor/applies/move.d.ts +6 -0
  59. package/dist/editor/applies/move.d.ts.map +1 -1
  60. package/dist/editor/applies/move.js +6 -0
  61. package/dist/editor/applies/move.js.map +1 -1
  62. package/dist/editor/applies/resize.d.ts +26 -5
  63. package/dist/editor/applies/resize.d.ts.map +1 -1
  64. package/dist/editor/applies/resize.js +91 -13
  65. package/dist/editor/applies/resize.js.map +1 -1
  66. package/dist/editor/caret-blink.d.ts +18 -0
  67. package/dist/editor/caret-blink.d.ts.map +1 -0
  68. package/dist/editor/caret-blink.js +42 -0
  69. package/dist/editor/caret-blink.js.map +1 -0
  70. package/dist/editor/container-ops.d.ts.map +1 -1
  71. package/dist/editor/container-ops.js +2 -2
  72. package/dist/editor/container-ops.js.map +1 -1
  73. package/dist/editor/dirty-rect.d.ts +23 -0
  74. package/dist/editor/dirty-rect.d.ts.map +1 -0
  75. package/dist/editor/dirty-rect.js +115 -0
  76. package/dist/editor/dirty-rect.js.map +1 -0
  77. package/dist/editor/gif-playback.d.ts +45 -0
  78. package/dist/editor/gif-playback.d.ts.map +1 -0
  79. package/dist/editor/gif-playback.js +125 -0
  80. package/dist/editor/gif-playback.js.map +1 -0
  81. package/dist/editor/hit-test.d.ts +3 -3
  82. package/dist/editor/hit-test.d.ts.map +1 -1
  83. package/dist/editor/hit-test.js +40 -15
  84. package/dist/editor/hit-test.js.map +1 -1
  85. package/dist/editor/long-press.d.ts.map +1 -1
  86. package/dist/editor/long-press.js +2 -2
  87. package/dist/editor/long-press.js.map +1 -1
  88. package/dist/editor/pinch.d.ts.map +1 -1
  89. package/dist/editor/pinch.js +6 -6
  90. package/dist/editor/pinch.js.map +1 -1
  91. package/dist/editor/pointer-binding.d.ts.map +1 -1
  92. package/dist/editor/pointer-binding.js +42 -25
  93. package/dist/editor/pointer-binding.js.map +1 -1
  94. package/dist/editor/public/arrange-group.d.ts +3 -9
  95. package/dist/editor/public/arrange-group.d.ts.map +1 -1
  96. package/dist/editor/public/arrange-group.js +4 -6
  97. package/dist/editor/public/arrange-group.js.map +1 -1
  98. package/dist/editor/public/clipboard.d.ts +2 -4
  99. package/dist/editor/public/clipboard.d.ts.map +1 -1
  100. package/dist/editor/public/clipboard.js +2 -4
  101. package/dist/editor/public/clipboard.js.map +1 -1
  102. package/dist/editor/public/cursor.d.ts +43 -0
  103. package/dist/editor/public/cursor.d.ts.map +1 -0
  104. package/dist/editor/public/cursor.js +135 -0
  105. package/dist/editor/public/cursor.js.map +1 -0
  106. package/dist/editor/public/frame-name.d.ts +20 -0
  107. package/dist/editor/public/frame-name.d.ts.map +1 -0
  108. package/dist/editor/public/frame-name.js +54 -0
  109. package/dist/editor/public/frame-name.js.map +1 -0
  110. package/dist/editor/public/image-insert.d.ts +1 -1
  111. package/dist/editor/public/image-insert.d.ts.map +1 -1
  112. package/dist/editor/public/layers.d.ts.map +1 -1
  113. package/dist/editor/public/layers.js +2 -2
  114. package/dist/editor/public/layers.js.map +1 -1
  115. package/dist/editor/public/link.d.ts +11 -2
  116. package/dist/editor/public/link.d.ts.map +1 -1
  117. package/dist/editor/public/link.js +70 -2
  118. package/dist/editor/public/link.js.map +1 -1
  119. package/dist/editor/public/placement.d.ts +60 -2
  120. package/dist/editor/public/placement.d.ts.map +1 -1
  121. package/dist/editor/public/placement.js +185 -2
  122. package/dist/editor/public/placement.js.map +1 -1
  123. package/dist/editor/public/selection-ops.d.ts +23 -1
  124. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  125. package/dist/editor/public/selection-ops.js +79 -2
  126. package/dist/editor/public/selection-ops.js.map +1 -1
  127. package/dist/editor/public/text-edit.d.ts +1 -10
  128. package/dist/editor/public/text-edit.d.ts.map +1 -1
  129. package/dist/editor/public/text-edit.js +2 -16
  130. package/dist/editor/public/text-edit.js.map +1 -1
  131. package/dist/editor/public/z-order.d.ts.map +1 -1
  132. package/dist/editor/public/z-order.js +4 -4
  133. package/dist/editor/public/z-order.js.map +1 -1
  134. package/dist/editor/public/zoom-pan.d.ts +6 -4
  135. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  136. package/dist/editor/public/zoom-pan.js +8 -7
  137. package/dist/editor/public/zoom-pan.js.map +1 -1
  138. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  139. package/dist/editor/render-orchestrator.js +4 -9
  140. package/dist/editor/render-orchestrator.js.map +1 -1
  141. package/dist/editor/resize-helpers.d.ts +21 -5
  142. package/dist/editor/resize-helpers.d.ts.map +1 -1
  143. package/dist/editor/resize-helpers.js +59 -19
  144. package/dist/editor/resize-helpers.js.map +1 -1
  145. package/dist/editor/viewport-helpers.d.ts.map +1 -1
  146. package/dist/editor/viewport-helpers.js +3 -3
  147. package/dist/editor/viewport-helpers.js.map +1 -1
  148. package/dist/editor-events.d.ts +1 -1
  149. package/dist/editor-events.d.ts.map +1 -1
  150. package/dist/editor.d.ts +125 -133
  151. package/dist/editor.d.ts.map +1 -1
  152. package/dist/editor.js +351 -989
  153. package/dist/editor.js.map +1 -1
  154. package/dist/file-drop.d.ts.map +1 -1
  155. package/dist/file-drop.js +1 -2
  156. package/dist/file-drop.js.map +1 -1
  157. package/dist/frame-helpers.d.ts.map +1 -1
  158. package/dist/frame-helpers.js +5 -5
  159. package/dist/frame-helpers.js.map +1 -1
  160. package/dist/group-helpers.d.ts.map +1 -1
  161. package/dist/group-helpers.js +4 -4
  162. package/dist/group-helpers.js.map +1 -1
  163. package/dist/handle.d.ts +61 -6
  164. package/dist/handle.d.ts.map +1 -1
  165. package/dist/handle.js +105 -7
  166. package/dist/handle.js.map +1 -1
  167. package/dist/hotkeys-binding.d.ts +26 -0
  168. package/dist/hotkeys-binding.d.ts.map +1 -0
  169. package/dist/hotkeys-binding.js +35 -0
  170. package/dist/hotkeys-binding.js.map +1 -0
  171. package/dist/index.d.ts +3 -2
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +3 -2
  174. package/dist/index.js.map +1 -1
  175. package/dist/link-selection.d.ts +3 -5
  176. package/dist/link-selection.d.ts.map +1 -1
  177. package/dist/link-selection.js +3 -12
  178. package/dist/link-selection.js.map +1 -1
  179. package/dist/machine.d.ts +27 -1
  180. package/dist/machine.d.ts.map +1 -1
  181. package/dist/machine.js +38 -0
  182. package/dist/machine.js.map +1 -1
  183. package/dist/overlay.d.ts +1 -4
  184. package/dist/overlay.d.ts.map +1 -1
  185. package/dist/overlay.js +136 -36
  186. package/dist/overlay.js.map +1 -1
  187. package/dist/selection.d.ts.map +1 -1
  188. package/dist/selection.js +2 -10
  189. package/dist/selection.js.map +1 -1
  190. package/dist/util.d.ts +8 -0
  191. package/dist/util.d.ts.map +1 -0
  192. package/dist/util.js +17 -0
  193. package/dist/util.js.map +1 -0
  194. package/package.json +7 -7
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,22 +2447,28 @@ export class Editor {
2667
2447
  this.snapSuppressed = suppressed;
2668
2448
  }
2669
2449
  /**
2670
- * True when a gesture should snap. Snapping is coupled to grid *display*:
2671
- * it is active only while a grid is actually shown the toggle is on
2672
- * (`gridVisible`, `g` hotkey) AND the scene has a positive `gridSize` (the
2673
- * same condition `renderGrid` paints under). Snapping to an invisible grid
2674
- * is confusing, so no grid → no snap, always. `snapToGrid` is an extra
2675
- * programmatic opt-out; the suppress modifier (Cmd/Ctrl) bypasses snapping
2676
- * for the current gesture.
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.
2677
2464
  */
2678
2465
  snapActive() {
2679
2466
  const viewport = this._scene.viewport;
2680
- const gridShown = this.gridVisible && (viewport.gridSize ?? 0) > 0;
2681
- return !this.snapSuppressed && gridShown && isSnapToGridEnabled(viewport);
2467
+ return !this.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport);
2682
2468
  }
2683
2469
  /** World-unit spacing the current gesture snaps to. */
2684
2470
  snapSpacing() {
2685
- return resolveSnapSpacing(this._scene.viewport);
2471
+ return resolveSnapSpacing();
2686
2472
  }
2687
2473
  /**
2688
2474
  * Replace the entire scene (e.g. after `parseScene`). Clears history,
@@ -2713,7 +2499,7 @@ export class Editor {
2713
2499
  }
2714
2500
  // Restore transient animationData (GIF bytes) from Scene.files
2715
2501
  // before the tick so the animation adapter can decode frames.
2716
- this.rehydrateAnimatedImages();
2502
+ animScene.rehydrateAnimatedImages(this);
2717
2503
  this.notify();
2718
2504
  // Loaded scene may carry animated shapes (e.g. GIF re-imported
2719
2505
  // from saved JSON). Re-arm the tick — `metadata.animated` survives
@@ -2731,20 +2517,14 @@ export class Editor {
2731
2517
  this.cursorListeners.clear();
2732
2518
  this.longPressListeners.clear();
2733
2519
  this.announceListeners.clear();
2734
- this.animationTick.stop();
2520
+ this.animation.detach();
2735
2521
  this.animationContentOff?.();
2736
- if (typeof document !== "undefined") {
2737
- document.removeEventListener("visibilitychange", this.onVisibilityChange);
2738
- }
2739
2522
  if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
2740
2523
  cancelAnimationFrame(this.renderRafId);
2741
2524
  this.renderRafId = null;
2742
2525
  }
2743
2526
  }
2744
2527
  // --- Internal ---
2745
- // Body moved to `./editor/pointer-binding.ts` (~700 lines of
2746
- // pointer / wheel / keyboard dispatch). The thin wrapper here
2747
- // preserves the original constructor call site.
2748
2528
  bindPointerEvents() {
2749
2529
  return bindPointerEventsExternal(this);
2750
2530
  }
@@ -2800,14 +2580,14 @@ export class Editor {
2800
2580
  isDrawingPhase(ctx) {
2801
2581
  return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
2802
2582
  }
2803
- // --- Long-press --- (controller in `./editor/long-press.ts`)
2583
+ // --- Long-press ---
2804
2584
  startLongPress(screenPoint) {
2805
2585
  this.longPress.start(screenPoint);
2806
2586
  }
2807
2587
  cancelLongPress() {
2808
2588
  this.longPress.cancel();
2809
2589
  }
2810
- // --- Pinch gesture --- (controller in `./editor/pinch.ts`)
2590
+ // --- Pinch gesture ---
2811
2591
  beginPinch() {
2812
2592
  this.pinch.begin([...this.activePointers.values()]);
2813
2593
  }
@@ -2822,9 +2602,9 @@ export class Editor {
2822
2602
  screenToWorld(point) {
2823
2603
  return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
2824
2604
  }
2825
- // Pure body in `./editor/hit-test.ts`. Editor passes a narrow
2826
- // context bundle that closes over its private state + accel
2827
- // helpers (acceleratedElementAt, isElementInteractable, …).
2605
+ // Editor passes a narrow context bundle that closes over its
2606
+ // private state + accel helpers (acceleratedElementAt,
2607
+ // isElementInteractable, …).
2828
2608
  /**
2829
2609
  * Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
2830
2610
  * interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
@@ -2867,87 +2647,10 @@ export class Editor {
2867
2647
  * out of sync. `worldPoint` defaults to the last known pointer position.
2868
2648
  */
2869
2649
  refreshCursor(worldPoint) {
2870
- const next = this.computeCursor(worldPoint ?? this.lastPointerWorld);
2650
+ const next = computeCursor(this, worldPoint ?? this.lastPointerWorld);
2871
2651
  if (this.host.style.cursor !== next)
2872
2652
  this.host.style.cursor = next;
2873
2653
  }
2874
- /**
2875
- * The CSS cursor for the current state. Priority: active gesture → text edit → pan affordance →
2876
- * draw tool → idle hover hit-test. Pure read of editor state; no side effects.
2877
- */
2878
- computeCursor(p) {
2879
- // Each outcome is a (role, fallback-keyword) pair; `resolveCursor` returns
2880
- // a host-registered custom image for that role if one exists, else the
2881
- // keyword. Roles are the stable override keys (see `setCursorOverride`).
2882
- const r = (role, keyword) => this.resolveCursor(role, keyword);
2883
- const resizeRole = (h) => r(RESIZE_ROLE[h], cursorForHandle(h));
2884
- // 1. Active gestures (highest priority — what the pointer is doing now).
2885
- if (this.panGesture)
2886
- return r("pan-active", "grabbing");
2887
- if (this.linkDragFromAnchor?.moved === true)
2888
- return r("draw", "crosshair");
2889
- if (this.isDraggingWaypoint || this.isDraggingSegment)
2890
- return r("move", "grabbing");
2891
- if (this.annotationDrag?.moved === true)
2892
- return r("move", "grabbing");
2893
- if (this.brushStroke)
2894
- return r("draw", "crosshair");
2895
- // Machine-driven drag past the threshold (`gestureTx` opens then): resize
2896
- // shows the handle's arrow; element / link move shows grabbing.
2897
- if (this.gestureTx) {
2898
- const t = this.actor.getSnapshot().context.pressTarget;
2899
- if (t && (t.kind === "handle" || t.kind === "group-handle"))
2900
- return resizeRole(t.handle);
2901
- if (t && (t.kind === "element" || t.kind === "link" || t.kind === "edge-endpoint")) {
2902
- return r("move", "grabbing");
2903
- }
2904
- }
2905
- // 2. In-canvas text editing → I-beam.
2906
- if (this.editingTextElement !== null)
2907
- return r("text", "text");
2908
- // 3. Pan affordance (idle): Space held or hand tool.
2909
- if (this.spaceHeld || this.mode === "hand")
2910
- return r("pan-ready", "grab");
2911
- // 4. Draw tools (idle, before a gesture starts).
2912
- switch (this.mode) {
2913
- case "draw-rect":
2914
- case "draw-ellipse":
2915
- case "draw-frame":
2916
- case "draw-edge":
2917
- case "brush":
2918
- return r("draw", "crosshair");
2919
- case "draw-text":
2920
- return r("text", "text");
2921
- default:
2922
- break;
2923
- }
2924
- // 5. Idle hover in select mode — key off the hit-test target.
2925
- if (p) {
2926
- if (this.isOverLinkStartDot(p))
2927
- return r("link-start", "crosshair");
2928
- const t = this.hitTest(p);
2929
- switch (t.kind) {
2930
- case "handle":
2931
- case "group-handle":
2932
- return resizeRole(t.handle);
2933
- case "edge-endpoint":
2934
- return r("link-handle", "grab");
2935
- case "annotation":
2936
- return r("annotation", "pointer");
2937
- default:
2938
- return r("default", "default");
2939
- }
2940
- }
2941
- return r("default", "default");
2942
- }
2943
- /**
2944
- * Resolve a cursor role to a CSS `cursor` value: a host-registered custom
2945
- * image (via {@link setCursorOverride}) if present, else `fallbackKeyword`.
2946
- */
2947
- resolveCursor(role, fallbackKeyword) {
2948
- const spec = this.cursorOverrides.get(role);
2949
- return spec === undefined ? fallbackKeyword : cssCursor(spec, fallbackKeyword);
2950
- }
2951
2654
  /**
2952
2655
  * Register (or clear, with `null`) a custom cursor image for a state role.
2953
2656
  * The image is shown wherever `computeCursor` resolves that role; pass a
@@ -2963,31 +2666,12 @@ export class Editor {
2963
2666
  this.refreshCursor();
2964
2667
  }
2965
2668
  /**
2966
- * True when `p` is within the grab radius of one of the single selected
2967
- * element's link-start dots used to show a `crosshair` (start a link).
2968
- * Mirrors the anchor-drag hit-test in pointer-binding so the cursor matches
2969
- * 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).
2970
2672
  */
2971
- isOverLinkStartDot(p) {
2972
- if (this.mode !== "select" || this._selection.size !== 1)
2973
- return false;
2974
- const id = [...this._selection][0];
2975
- if (id === undefined)
2976
- return false;
2977
- const shape = getElement(this._scene, id);
2978
- if (!shape)
2979
- return false;
2980
- const zoom = this._scene.viewport.zoom || 1;
2981
- const { worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
2982
- const grab = (ANCHOR_DOT_ACTIVE_RADIUS + this.anchorStartHitSlop) / zoom;
2983
- const grab2 = grab * grab;
2984
- for (const wp of worldPoints) {
2985
- const dx = wp.x - p.x;
2986
- const dy = wp.y - p.y;
2987
- if (dx * dx + dy * dy <= grab2)
2988
- return true;
2989
- }
2990
- return false;
2673
+ getCursorOverride(role) {
2674
+ return this.cursorOverrides.get(role);
2991
2675
  }
2992
2676
  /** True when the given layer exists and is marked `locked`. */
2993
2677
  isLayerLocked(layerId) {
@@ -3075,8 +2759,7 @@ export class Editor {
3075
2759
  * Topmost group ancestor of `shape` (walks parentId chain, returns
3076
2760
  * the highest `type === "group"` parent). `null` if `shape` has no
3077
2761
  * group ancestor. Used by drill-down: a double-click on a shape
3078
- * with a group ancestor enters that group. Body extracted to
3079
- * `./group-helpers.ts`.
2762
+ * with a group ancestor enters that group.
3080
2763
  */
3081
2764
  topGroupAncestor(shape) {
3082
2765
  return topGroupAncestorHelper(this._scene, shape);
@@ -3103,7 +2786,6 @@ export class Editor {
3103
2786
  * practice, but the guard keeps the contract simple — "what you've
3104
2787
  * selected, you can see".
3105
2788
  */
3106
- // Body moved to `./editor/shape-filters.ts`.
3107
2789
  computeHiddenElements() {
3108
2790
  return computeHiddenElementsPure(this._scene);
3109
2791
  }
@@ -3133,7 +2815,7 @@ export class Editor {
3133
2815
  const el = getElement(this._scene, id);
3134
2816
  if (!el)
3135
2817
  return;
3136
- const isFrameEl = el.type === "frame";
2818
+ const isFrameEl = isFrame(el);
3137
2819
  const members = [];
3138
2820
  for (const s of this._scene.elements.values()) {
3139
2821
  if (s.parentId === id || (isFrameEl && s.frameId === id))
@@ -3141,7 +2823,7 @@ export class Editor {
3141
2823
  }
3142
2824
  if (members.length === 0)
3143
2825
  return;
3144
- if (el.type === "group")
2826
+ if (isGroup(el))
3145
2827
  this._enteredGroup = id;
3146
2828
  this.setSelection(members);
3147
2829
  }
@@ -3235,7 +2917,7 @@ export class Editor {
3235
2917
  const now = performance.now();
3236
2918
  const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
3237
2919
  this.lastClickWorldPoint !== null &&
3238
- distanceTo(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
2920
+ vec2.distance(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
3239
2921
  this.lastClickAt = now;
3240
2922
  this.lastClickWorldPoint = worldPoint;
3241
2923
  // Double-click the frame HEADER (label strip above the body) → rename.
@@ -3282,11 +2964,11 @@ export class Editor {
3282
2964
  if (isDouble &&
3283
2965
  (clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
3284
2966
  const raw = this.acceleratedElementAt(worldPoint);
3285
- if (raw?.type === "text") {
2967
+ if (raw !== undefined && isText(raw)) {
3286
2968
  this.beginTextEdit(raw.id);
3287
2969
  return true;
3288
2970
  }
3289
- if (raw?.type === "frame") {
2971
+ if (raw !== undefined && isFrame(raw)) {
3290
2972
  this.beginFrameNameEdit(raw.id);
3291
2973
  return true;
3292
2974
  }
@@ -3401,6 +3083,9 @@ export class Editor {
3401
3083
  case "RESIZE_SHAPE":
3402
3084
  this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
3403
3085
  return;
3086
+ case "ROTATE":
3087
+ this.applyRotate(emit.deltaAngle);
3088
+ return;
3404
3089
  case "CREATE_SHAPE":
3405
3090
  this.applyCreate(emit.shapeType, emit.bounds);
3406
3091
  return;
@@ -3447,7 +3132,6 @@ export class Editor {
3447
3132
  * Wrapped in a single gestureTx so per-move updates collapse
3448
3133
  * into one undo step.
3449
3134
  */
3450
- // Pure body in `./editor/applies/move.ts`.
3451
3135
  applyAnnotationMove(id, delta, origin) {
3452
3136
  const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
3453
3137
  if (!result)
@@ -3483,13 +3167,14 @@ export class Editor {
3483
3167
  dispatchTemplateDrop(emit) {
3484
3168
  this.applyEmit(emit);
3485
3169
  }
3486
- // Pure body in `./editor/applies/move.ts`.
3487
3170
  applyMove(id, delta, originalBounds) {
3488
3171
  // Locked / layer-locked elements are selectable but don't move.
3489
3172
  const el = getElement(this._scene, id);
3490
3173
  if (el && !this.isElementManipulable(el))
3491
3174
  return;
3492
- 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;
3493
3178
  const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
3494
3179
  if (!patch)
3495
3180
  return;
@@ -3500,9 +3185,11 @@ export class Editor {
3500
3185
  applyGroupMove(delta) {
3501
3186
  if (!this.groupMoveOrigin)
3502
3187
  return;
3188
+ // Shift constrains the drag to one axis before snapping.
3189
+ const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
3503
3190
  const d = this.snapActive()
3504
- ? snapGroupDelta(this.groupMoveOrigin, delta, this.snapSpacing())
3505
- : delta;
3191
+ ? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
3192
+ : moved;
3506
3193
  const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
3507
3194
  for (const patch of patches) {
3508
3195
  this._scene = apply(this._scene, patch);
@@ -3520,7 +3207,6 @@ export class Editor {
3520
3207
  }
3521
3208
  this.notify();
3522
3209
  }
3523
- // Body moved to `./editor/viewport-helpers.ts`.
3524
3210
  computeViewportWorld() {
3525
3211
  return computeViewportWorldPure(this._scene);
3526
3212
  }
@@ -3589,125 +3275,16 @@ export class Editor {
3589
3275
  // repaint when the entered-group identity changes between frames.
3590
3276
  if (this.lastRenderedEnteredGroup !== this._enteredGroup)
3591
3277
  return null;
3592
- // Scene ref unchanged nothing changed on main canvas skip the
3593
- // whole pass via an empty off-screen rect that the dirty filter
3594
- // culls every shape against.
3595
- if (prev === next) {
3596
- return { x: -1e9, y: -1e9, width: 0, height: 0 };
3597
- }
3598
- let acc = null;
3599
- const add = (b) => {
3600
- acc = acc ? B.union(acc, b) : b;
3601
- };
3602
- // Track shapes that changed (added / removed / mutated). Links
3603
- // attached to any of these have stale rendered paths even when
3604
- // the edge object itself is reference-equal — the path resolves
3605
- // through the shape's new position, but the old path stays on
3606
- // screen as a "ghost" trail unless we explicitly invalidate it.
3607
- const changedElementIds = new Set();
3608
- for (const [id, shape] of next.elements) {
3609
- const old = prev.elements.get(id);
3610
- if (old === shape)
3611
- continue;
3612
- changedElementIds.add(id);
3613
- // Render bounds (not geometric) so overpaint — a frame's header
3614
- // strip, confetti particles — is cleared too, no ghost trail.
3615
- const afterBounds = getElementRenderBounds(shape);
3616
- const beforeBounds = old ? getElementRenderBounds(old) : null;
3617
- add(afterBounds);
3618
- if (beforeBounds)
3619
- add(beforeBounds);
3620
- // Stash for the tile-cache path — covers add + move via
3621
- // before/after pair; pure mutation re-uses the single
3622
- // afterBounds rect.
3623
- if (this.tileComposeFn !== null) {
3624
- this.tileDirtyElements.set(id, { before: beforeBounds, after: afterBounds });
3625
- }
3626
- }
3627
- for (const [id, shape] of prev.elements) {
3628
- if (!next.elements.has(id)) {
3629
- changedElementIds.add(id);
3630
- // Render bounds so a removed frame/confetti clears its overpaint.
3631
- const beforeBounds = getElementRenderBounds(shape);
3632
- add(beforeBounds);
3633
- if (this.tileComposeFn !== null) {
3634
- this.tileDirtyElements.set(id, { before: beforeBounds, after: null });
3635
- }
3636
- }
3637
- }
3638
- const linkTouchesChangedElement = (edge) => {
3639
- for (const ep of [edge.from, edge.to]) {
3640
- if (ep.kind !== "point") {
3641
- if (changedElementIds.has(ep.elementId))
3642
- return true;
3643
- }
3644
- }
3645
- return false;
3646
- };
3647
- for (const [id, edge] of next.links) {
3648
- const old = prev.links.get(id);
3649
- // Refresh edge dirty-rect when: edge object changed, OR an
3650
- // endpoint references a shape that moved this frame (path is
3651
- // re-resolved every render but the old screen pixels persist).
3652
- if (old === edge && !linkTouchesChangedElement(edge))
3653
- continue;
3654
- const b = computeLinkWorldBounds(next, edge);
3655
- if (b)
3656
- add(b);
3657
- const oldLink = old ?? edge; // prev scene resolves with prev shapes for ghost-clear
3658
- const ob = computeLinkWorldBounds(prev, oldLink);
3659
- if (ob)
3660
- 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);
3661
3285
  }
3662
- for (const [id, edge] of prev.links) {
3663
- if (!next.links.has(id)) {
3664
- const b = computeLinkWorldBounds(prev, edge);
3665
- if (b)
3666
- add(b);
3667
- }
3668
- }
3669
- // 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
3670
- if (acc === null)
3671
- return { x: -1e9, y: -1e9, width: 0, height: 0 };
3672
- // Transitive expansion: any shape whose bounds intersect the
3673
- // current dirty rect must be repainted, AND its bounds added
3674
- // to the dirty rect so any shape ABOVE it that overlaps gets
3675
- // included too. Repeat until the set stabilises.
3676
- //
3677
- // Without this, dragging A through a B/C stack produces
3678
- // visual jitter: B intersects the dirty rect and gets
3679
- // repainted, but C — sitting above B and partially overlapping
3680
- // it — doesn't intersect the original dirty, so B re-emerges
3681
- // on top of where C should still be drawn. Z-order is correct
3682
- // in `getElementsInLayer`; the issue is missed shapes, not
3683
- // wrong order.
3684
- const visited = new Set();
3685
- let expanded = acc;
3686
- let grew = true;
3687
- while (grew) {
3688
- grew = false;
3689
- for (const shape of next.elements.values()) {
3690
- if (visited.has(shape.id))
3691
- continue;
3692
- const bb = getElementWorldBounds(shape);
3693
- if (!B.intersects(bb, expanded))
3694
- continue;
3695
- visited.add(shape.id);
3696
- const merged = B.union(expanded, bb);
3697
- if (merged.x !== expanded.x ||
3698
- merged.y !== expanded.y ||
3699
- merged.width !== expanded.width ||
3700
- merged.height !== expanded.height) {
3701
- expanded = merged;
3702
- grew = true;
3703
- }
3704
- }
3705
- }
3706
- // Inflate by a couple pixels to cover anti-aliased stroke fuzz
3707
- // around the geometry edges.
3708
- return B.expand(expanded, 4);
3286
+ return world;
3709
3287
  }
3710
- // Bodies moved to `./editor/viewport-helpers.ts`.
3711
3288
  combinedSelectionBounds() {
3712
3289
  let acc = combinedSelectionBoundsPure(this._scene, this._selection);
3713
3290
  // Selected links join the selection box (standard parity) — union in
@@ -3763,19 +3340,42 @@ export class Editor {
3763
3340
  // Multi-selection: lock when every selected shape is an image — they
3764
3341
  // must never be stretched out of ratio, only scaled together.
3765
3342
  for (const id of this._selection) {
3766
- if (getElement(this._scene, id)?.type !== "image")
3343
+ const el = getElement(this._scene, id);
3344
+ if (el === undefined || !isImage(el))
3767
3345
  return false;
3768
3346
  }
3769
3347
  return true;
3770
3348
  }
3771
- // 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
+ }
3772
3369
  applyGroupResize(handle, delta, originalBounds) {
3773
3370
  if (!this.groupResizeOrigin)
3774
3371
  return;
3775
3372
  const d = this.snapActive()
3776
3373
  ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
3777
3374
  : delta;
3778
- 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);
3779
3379
  this._scene = result.scene;
3780
3380
  for (const patch of result.patches)
3781
3381
  this.recordGesturePatch(patch);
@@ -3783,16 +3383,32 @@ export class Editor {
3783
3383
  }
3784
3384
  applyResize(id, handle, delta, originalBounds) {
3785
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
+ }
3786
3402
  const d = this.snapActive()
3787
3403
  ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
3788
3404
  : delta;
3789
3405
  // Text: aspect-locked font scaling. Snapshot the pristine shape on
3790
3406
  // the gesture's first tick so the scale base never compounds.
3791
- if (shape?.type === "text") {
3407
+ if (shape !== undefined && isText(shape)) {
3792
3408
  if (this._resizeOriginElement?.id !== id) {
3793
3409
  this._resizeOriginElement = shape;
3794
3410
  }
3795
- const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds);
3411
+ const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds, this.transformAltKey);
3796
3412
  if (!result)
3797
3413
  return;
3798
3414
  this._scene = result.scene;
@@ -3800,14 +3416,13 @@ export class Editor {
3800
3416
  this.notify();
3801
3417
  return;
3802
3418
  }
3803
- 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);
3804
3420
  if (!result)
3805
3421
  return;
3806
3422
  this._scene = result.scene;
3807
3423
  this.recordGesturePatch(result.patch);
3808
3424
  this.notify();
3809
3425
  }
3810
- // Pure body in `./editor/applies/create.ts`.
3811
3426
  applyCreate(kind, bounds) {
3812
3427
  const id = newElementId(++this.nextId);
3813
3428
  const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
@@ -3847,8 +3462,7 @@ export class Editor {
3847
3462
  reconcileFrameMembership() {
3848
3463
  this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
3849
3464
  }
3850
- // Pure body in `./editor/applies/create.ts`. Endpoint snapping
3851
- // stays here because it needs the snap engine.
3465
+ // Endpoint snapping stays here because it needs the snap engine.
3852
3466
  applyCreateLink(emit) {
3853
3467
  const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
3854
3468
  const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
@@ -3887,35 +3501,11 @@ export class Editor {
3887
3501
  return;
3888
3502
  }
3889
3503
  const newId = newElementId(++this.nextId);
3890
- const order = orderForTop([...this._scene.elements.values()]
3891
- .filter((sh) => sh.layerId === this._activeLayerId)
3892
- .map((sh) => sh.order));
3893
- const built = factory({
3894
- id: newId,
3895
- layerId: this._activeLayerId,
3896
- position: pending.world,
3897
- order,
3898
- });
3899
- // Centre the element on the drop point regardless of how the factory
3900
- // anchored it at `position`.
3901
- const wb = getElementWorldBounds(built);
3902
- const shape = {
3903
- ...built,
3904
- position: {
3905
- x: built.position.x + (pending.world.x - (wb.x + wb.width / 2)),
3906
- y: built.position.y + (pending.world.y - (wb.y + wb.height / 2)),
3907
- },
3908
- };
3504
+ const r = computeShapeAtLinkDrop(this._scene, this._activeLayerId, pending, newId, factory);
3909
3505
  const tx = this._history.transaction();
3910
- const added = addElement(this._scene, shape);
3911
- this._scene = added.scene;
3912
- tx.add(added.patch);
3913
- const upd = updateLink(this._scene, pending.linkId, (e) => ({
3914
- ...e,
3915
- [pending.side]: { kind: "floating", elementId: newId },
3916
- }));
3917
- this._scene = upd.scene;
3918
- tx.add(upd.patch);
3506
+ this._scene = r.scene;
3507
+ tx.add(r.addPatch);
3508
+ tx.add(r.linkPatch);
3919
3509
  tx.commit();
3920
3510
  this.pendingLinkDropMenu = null;
3921
3511
  this._selection = Selection.single(newId);
@@ -3937,50 +3527,15 @@ export class Editor {
3937
3527
  * selection. Element + link land in one undo step.
3938
3528
  */
3939
3529
  createLinkedElementFromAnchor(fromElement, anchorName) {
3940
- const src = getElement(this._scene, fromElement);
3941
- if (!src)
3942
- return;
3943
- const anchor = { kind: "named", name: anchorName };
3944
- const normal = getAnchorOutwardNormal(src, anchor);
3945
- const bounds = getElementWorldBounds(src);
3946
- const srcCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
3947
- // Same-size clone → centre-to-centre distance = the source's extent
3948
- // along the normal + the gap, leaving exactly
3949
- // ANCHOR_CLICK_NEW_ELEMENT_GAP between the facing edges. `extentAlong`
3950
- // resolves to width for a horizontal normal, height for a vertical one
3951
- // (link-start dots are the four edge midpoints).
3952
- const extentAlong = Math.abs(normal.x) * bounds.width + Math.abs(normal.y) * bounds.height;
3953
- const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
3954
- const delta = { x: normal.x * dist, y: normal.y * dist };
3955
3530
  const newId = newElementId(++this.nextId);
3956
- const order = orderForTop([...this._scene.elements.values()]
3957
- .filter((sh) => sh.layerId === src.layerId)
3958
- .map((sh) => sh.order));
3959
- let clone = {
3960
- ...src,
3961
- id: newId,
3962
- position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
3963
- order,
3964
- };
3965
- // Blank user text — the new element is a fresh same-kind shape, not a
3966
- // content copy (standard). Only `text` (TextElement) and `name`
3967
- // (FrameElement) carry user-entered text. Cast through `Element` because
3968
- // `exactOptionalPropertyTypes` rejects the bare object literal against
3969
- // the union (TS2375), even though the narrowed branch is sound.
3970
- if (clone.type === "text")
3971
- clone = { ...clone, text: "" };
3972
- else if (clone.type === "frame")
3973
- clone = { ...clone, name: "" };
3974
- const tx = this._history.transaction();
3975
- const added = addElement(this._scene, clone);
3976
- this._scene = added.scene;
3977
- tx.add(added.patch);
3978
3531
  const linkId = newLinkId(++this.nextId);
3979
- const placed = req(getElement(this._scene, newId));
3980
- const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
3981
- const linkResult = computeCreateLink(this._scene, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: newId, anchor: toRef }, linkId, this._activeLayerId);
3982
- this._scene = linkResult.scene;
3983
- 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);
3984
3539
  tx.commit();
3985
3540
  this._selection = Selection.single(newId);
3986
3541
  if (this._selectedLinks.size > 0)
@@ -3994,72 +3549,7 @@ export class Editor {
3994
3549
  * `createLinkedElementFromAnchor`.
3995
3550
  */
3996
3551
  previewClickCreate(fromElement, anchorName) {
3997
- const src = getElement(this._scene, fromElement);
3998
- if (!src)
3999
- return null;
4000
- const anchor = { kind: "named", name: anchorName };
4001
- const normal = getAnchorOutwardNormal(src, anchor);
4002
- const b = getElementWorldBounds(src);
4003
- const extentAlong = Math.abs(normal.x) * b.width + Math.abs(normal.y) * b.height;
4004
- const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
4005
- const delta = { x: normal.x * dist, y: normal.y * dist };
4006
- const bounds = { x: b.x + delta.x, y: b.y + delta.y, width: b.width, height: b.height };
4007
- const fromWorld = getAnchorWorld(src, anchor);
4008
- // Facing edge of the ghost (toward the source) = its centre pulled back
4009
- // along the normal by half its extent.
4010
- const ghostCx = bounds.x + bounds.width / 2;
4011
- const ghostCy = bounds.y + bounds.height / 2;
4012
- const nearEdge = {
4013
- x: ghostCx - normal.x * (extentAlong / 2),
4014
- y: ghostCy - normal.y * (extentAlong / 2),
4015
- };
4016
- // The would-be element itself — a same-kind clone of the source shifted
4017
- // to the ghost bounds, with blank user text (mirrors
4018
- // `createLinkedElementFromAnchor`). The overlay renders THIS through the
4019
- // real renderer so the ghost looks like the actual shape (an ellipse
4020
- // ghosts as an ellipse), not a bounding rect. Throwaway id — never enters
4021
- // the real scene.
4022
- let element = {
4023
- ...src,
4024
- id: PREVIEW_GHOST_ELEMENT_ID,
4025
- position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
4026
- };
4027
- if (element.type === "text")
4028
- element = { ...element, text: "" };
4029
- else if (element.type === "frame")
4030
- element = { ...element, name: "" };
4031
- // Build a throwaway scene holding the ghost element + the would-be link so
4032
- // the connector can be drawn through the REAL link renderer (same routing,
4033
- // arrowhead and style it'll have once created) — faded — instead of a
4034
- // dashed preview line. Mirrors the link build in
4035
- // `createLinkedElementFromAnchor` exactly. The `path` field stays for
4036
- // callers that just want the straight from→to segment.
4037
- const srcCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
4038
- const withGhost = addElement(this._scene, element).scene;
4039
- const placed = req(getElement(withGhost, PREVIEW_GHOST_ELEMENT_ID));
4040
- const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
4041
- const linkResult = computeCreateLink(withGhost, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: PREVIEW_GHOST_ELEMENT_ID, anchor: toRef }, PREVIEW_GHOST_LINK_ID, this._activeLayerId);
4042
- let ghostScene = linkResult.scene;
4043
- const edge = req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID));
4044
- if ((edge.routing ?? "straight") === "orthogonal") {
4045
- const routedPoints = routeElbowLink(ghostScene, edge);
4046
- ghostScene = updateLink(ghostScene, PREVIEW_GHOST_LINK_ID, (e) => ({
4047
- ...e,
4048
- routedPoints,
4049
- })).scene;
4050
- }
4051
- // Render only the ghost link (the shapes stay for endpoint resolution).
4052
- ghostScene = {
4053
- ...ghostScene,
4054
- links: new Map([[PREVIEW_GHOST_LINK_ID, req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID))]]),
4055
- };
4056
- return {
4057
- bounds,
4058
- path: [fromWorld, nearEdge],
4059
- element,
4060
- ghostScene,
4061
- ghostLinkId: PREVIEW_GHOST_LINK_ID,
4062
- };
3552
+ return previewClickCreatePure(this._scene, this._activeLayerId, fromElement, anchorName);
4063
3553
  }
4064
3554
  /**
4065
3555
  * Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
@@ -4073,61 +3563,10 @@ export class Editor {
4073
3563
  * shapes when the user clearly aimed for this one).
4074
3564
  */
4075
3565
  snapLinkEndpoint(pressTargetElement, worldPoint) {
4076
- const result = this.snapEngine.snap({
4077
- scene: this._scene,
4078
- probe: worldPoint,
4079
- threshold: this.snapThreshold,
4080
- gesture: "draw-edge",
4081
- });
4082
- // Attach contract: dropping on a port dot → *fixed* anchor; dropping
4083
- // near an EDGE (not a dot) → *fixed* outline point (a ratio along the
4084
- // perimeter — survives move/resize); dropping on the body interior (no
4085
- // snap to a dot or edge) → *floating* against the whole shape, so the
4086
- // connection re-aims at the partner as either shape moves; dropping on
4087
- // empty canvas → a free point.
4088
- //
4089
- // Pick a candidate ON the pressed shape if there is one, otherwise the
4090
- // nearest overall. The "nearest overall" branch matters because the
4091
- // attach DOTS are drawn OUTSIDE the body — a release on a dot finds no
4092
- // element under it (hit-test = empty), so `pressTargetElement` is null,
4093
- // yet the snap engine still reports the dot's anchor within threshold.
4094
- // Binding it makes the endpoint enter PERPENDICULAR to that edge instead
4095
- // of staying a free point that aims at the partner (jumping between the
4096
- // four sides).
4097
- const pick = (kind) => {
4098
- if (pressTargetElement !== null) {
4099
- const onTarget = result.all.find((c) => c.kind === kind && c.metadata?.elementId === pressTargetElement);
4100
- if (onTarget)
4101
- return onTarget;
4102
- }
4103
- return result.all.find((c) => c.kind === kind);
4104
- };
4105
- const boundFrom = (cand, want) => {
4106
- if (!cand)
4107
- return null;
4108
- const elId = cand.metadata?.elementId;
4109
- if (elId === undefined)
4110
- return null;
4111
- const shp = getElement(this._scene, elId);
4112
- if (!shp)
4113
- return null;
4114
- const ep = endpointFromSnap(elId, cand, shp);
4115
- return ep.kind === want ? ep : null;
4116
- };
4117
- const anchorEp = boundFrom(pick("anchor"), "anchor");
4118
- if (anchorEp)
4119
- return anchorEp;
4120
- const outlineEp = boundFrom(pick("outline"), "outline");
4121
- if (outlineEp)
4122
- return outlineEp;
4123
- // No dot/edge snap. Over a shape body → floating; else a free point.
4124
- if (pressTargetElement !== null && getElement(this._scene, pressTargetElement)) {
4125
- return { kind: "floating", elementId: pressTargetElement };
4126
- }
4127
- return { kind: "point", position: worldPoint };
3566
+ return snapLinkEndpointPure(this._scene, this.snapEngine, this.snapThreshold, pressTargetElement, worldPoint);
4128
3567
  }
4129
- // Pure body in `./editor/applies/selection.ts`. The wrappers
4130
- // here own the side effects (`_selectedLink` clearing, notify).
3568
+ // The wrappers here own the side effects (`_selectedLink`
3569
+ // clearing, notify).
4131
3570
  applySelectByBounds(bounds, mode) {
4132
3571
  const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
4133
3572
  const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
@@ -4151,9 +3590,8 @@ export class Editor {
4151
3590
  this._selectedLinks = nextLinks;
4152
3591
  this._selection = next;
4153
3592
  }
4154
- // Pure body in `./editor/applies/edge.ts`. The wrapper here
4155
- // owns the side effects (history push, drag-state clearing,
4156
- // notify).
3593
+ // The wrapper here owns the side effects (history push,
3594
+ // drag-state clearing, notify).
4157
3595
  /**
4158
3596
  * Live endpoint-rebind move: re-point the dragged end to the cursor in the
4159
3597
  * scene (a free `point` endpoint), recorded in the gesture transaction so the
@@ -4277,7 +3715,7 @@ export class Editor {
4277
3715
  const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
4278
3716
  const a = path?.[drag.index];
4279
3717
  const b = path?.[drag.index + 2];
4280
- if (a && b && distanceToSegmentPt(wp, a, b) <= collapse) {
3718
+ if (a && b && hitTest.distanceToSegment(wp, a, b) <= collapse) {
4281
3719
  const wps = edge.waypoints.filter((_, i) => i !== drag.index);
4282
3720
  const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
4283
3721
  this._scene = r.scene;
@@ -4346,7 +3784,7 @@ export class Editor {
4346
3784
  const now = performance.now();
4347
3785
  const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
4348
3786
  this.lastHandleClickWorld !== null &&
4349
- distanceTo(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
3787
+ vec2.distance(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
4350
3788
  this.lastHandleClickAt = now;
4351
3789
  this.lastHandleClickWorld = world;
4352
3790
  return isDouble;
@@ -4474,7 +3912,6 @@ export class Editor {
4474
3912
  this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
4475
3913
  this.notify();
4476
3914
  }
4477
- // Pure body in `./editor/applies/edge.ts`.
4478
3915
  applyLinkPreview(fromElement, fromPoint, toPoint) {
4479
3916
  const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
4480
3917
  // Match the preview to the connector that will be committed: when new
@@ -4491,9 +3928,7 @@ export class Editor {
4491
3928
  this.notify();
4492
3929
  }
4493
3930
  // Gesture lifecycle — recordGesturePatch / commitGesture /
4494
- // cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate
4495
- // live in `./editor/gesture-tx.ts`. The thin instance methods below
4496
- // preserve the original call sites.
3931
+ // cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate.
4497
3932
  recordGesturePatch(patch) {
4498
3933
  // Snapshot the pre-gesture scene the moment the transaction opens, so a
4499
3934
  // later cancel/Escape can restore it (the history tx only records undo data,
@@ -4505,6 +3940,7 @@ export class Editor {
4505
3940
  }
4506
3941
  commitGesture() {
4507
3942
  this._resizeOriginElement = null;
3943
+ this.rotateGestureOrigin = null;
4508
3944
  this.gestures.commit();
4509
3945
  this.gestureStartScene = null;
4510
3946
  }
@@ -4525,9 +3961,9 @@ export class Editor {
4525
3961
  * - Cycles (a container inside its own descendant) are prevented by the
4526
3962
  * `containerHover` pipeline above — the exclude set rules them out.
4527
3963
  */
4528
- // Pure body in `./editor/container-ops.ts`. Editor exposes a
4529
- // small `ContainerOpsRef` bridge so the module can mutate scene
4530
- // + 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.
4531
3967
  applyContainerDrop(worldPoint) {
4532
3968
  applyContainerDropPure(this.containerOpsRef, worldPoint);
4533
3969
  }
@@ -4535,7 +3971,6 @@ export class Editor {
4535
3971
  maybeGrowContainer(containerId, childId) {
4536
3972
  maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
4537
3973
  }
4538
- // Pure body in `./editor/container-ops.ts`.
4539
3974
  clampContainerToChildren(shape, raw, handle) {
4540
3975
  return clampContainerToChildrenPure(this._scene, shape, raw, handle);
4541
3976
  }
@@ -4548,9 +3983,9 @@ export class Editor {
4548
3983
  this.gestureTx ??= this._history.transaction();
4549
3984
  return this.gestureTx;
4550
3985
  }
4551
- // Body moved to `./editor/gesture-tx.ts`.
4552
3986
  cancelGesture() {
4553
3987
  this._resizeOriginElement = null;
3988
+ this.rotateGestureOrigin = null;
4554
3989
  this.gestures.cancel();
4555
3990
  // Roll the scene back to the pre-gesture snapshot — cancelling the history
4556
3991
  // transaction alone leaves the live drag mutations in `_scene`.
@@ -4642,9 +4077,8 @@ export class Editor {
4642
4077
  /**
4643
4078
  * Typed event surface — subscribe to a specific slice (`mode`,
4644
4079
  * `selection`, `scene`, `history`, `viewport`) or the umbrella
4645
- * `change`. Replaces ad-hoc selectors over the coarse `subscribe()`
4646
- * for callers that only care about one dimension. The legacy
4647
- * `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.
4648
4082
  */
4649
4083
  on(event, fn) {
4650
4084
  // Cast through `never`: TS can't prove that EditorEvents[K]
@@ -4683,33 +4117,6 @@ export class Editor {
4683
4117
  * between frames the A* route is reused — see `rerouteElbows`.
4684
4118
  */
4685
4119
  elbowRouteSig = new Map();
4686
- elbowSignature(edge) {
4687
- const part = (ep) => {
4688
- if (ep.kind === "point")
4689
- return `p:${ep.position.x},${ep.position.y}`;
4690
- const s = getElement(this._scene, ep.elementId);
4691
- const b = s ? getElementWorldBounds(s) : null;
4692
- const ref = ep.kind === "anchor"
4693
- ? JSON.stringify(ep.anchor)
4694
- : ep.kind === "outline"
4695
- ? `o:${ep.ratio}`
4696
- : "f";
4697
- return `${ep.kind}:${ep.elementId}:${ref}:${b ? `${b.x},${b.y},${b.width},${b.height}` : "x"}`;
4698
- };
4699
- const base = `${part(edge.from)}|${part(edge.to)}|${JSON.stringify(edge.fixedSegments ?? null)}`;
4700
- // Avoid-obstacles links depend on EVERY shape's geometry, so their route
4701
- // must invalidate when any obstacle moves — fold a digest of all element
4702
- // bboxes into the signature. Only paid by links that opt in.
4703
- if (edge.avoidObstacles === true) {
4704
- let digest = "|avoid:";
4705
- for (const el of this._scene.elements.values()) {
4706
- const bb = getElementWorldBounds(el);
4707
- digest += `${el.id},${bb.x},${bb.y},${bb.width},${bb.height};`;
4708
- }
4709
- return base + digest;
4710
- }
4711
- return base;
4712
- }
4713
4120
  /**
4714
4121
  * Choke-point reroute (standard model): recompute `routedPoints` for
4715
4122
  * every orthogonal link whose inputs changed since the last pass, and
@@ -4722,7 +4129,7 @@ export class Editor {
4722
4129
  for (const [id, edge] of this._scene.links) {
4723
4130
  if ((edge.routing ?? "straight") !== "orthogonal")
4724
4131
  continue;
4725
- const sig = this.elbowSignature(edge);
4132
+ const sig = elbowSignature(this._scene, edge);
4726
4133
  if (this.elbowRouteSig.get(id) === sig)
4727
4134
  continue;
4728
4135
  this.elbowRouteSig.set(id, sig);
@@ -4731,32 +4138,19 @@ export class Editor {
4731
4138
  }
4732
4139
  this._scene = next;
4733
4140
  }
4734
- // Pure body in `./editor/render-orchestrator.ts` (~130 lines).
4735
4141
  render() {
4736
4142
  this.rerouteElbows();
4737
4143
  // Feed the renderer's animation clock our per-shape playback state
4738
4144
  // so paused / reduced-motion GIFs freeze and resumed ones continue
4739
4145
  // from the right frame. Set immediately before the synchronous
4740
4146
  // render pass (the shape-renderer has no options channel).
4741
- setAnimationClock((shape) => this.playbackClock(shape.id));
4147
+ setAnimationClock((shape) => this.gifPlayback.clock(shape.id));
4742
4148
  renderEditor(this);
4743
4149
  // Present AFTER the paint, on the same tick — deferred-submission
4744
4150
  // surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
4745
4151
  this.onAfterRender?.();
4746
4152
  }
4747
4153
  }
4748
- /**
4749
- * Throwaway id for the transient click-create ghost preview element built by
4750
- * `previewClickCreate`. Never enters the scene / history — it lives only for
4751
- * the duration of one overlay paint, so any stable constant is fine.
4752
- */
4753
- const PREVIEW_GHOST_ELEMENT_ID = "__ghost-preview__";
4754
- /** Throwaway link id for the click-create ghost preview. See above. */
4755
- const PREVIEW_GHOST_LINK_ID = "__ghost-preview-link__";
4756
- const distanceTo = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
4757
- // `coverageRatio` moved to `./editor/container-ops.ts`.
4758
- // `hasWidthHeight` moved to `./editor/shape-traits.ts` for shared
4759
- // use by container-ops and the future applies/resize module.
4760
4154
  /**
4761
4155
  * Type guard — `true` when the value already implements the
4762
4156
  * `HistoryProvider` surface. Used to decide between "host supplied
@@ -4772,37 +4166,5 @@ const isHistoryProvider = (value) => {
4772
4166
  typeof value.redo === "function" &&
4773
4167
  typeof value.transaction === "function");
4774
4168
  };
4775
- // `describeNudge` moved to `./editor/public/selection-ops.ts`.
4776
4169
  /** Distance from point `p` to the finite segment `a`–`b` (world space). */
4777
- function distanceToSegmentPt(p, a, b) {
4778
- const dx = b.x - a.x;
4779
- const dy = b.y - a.y;
4780
- const lenSq = dx * dx + dy * dy;
4781
- if (lenSq === 0)
4782
- return Math.hypot(p.x - a.x, p.y - a.y);
4783
- let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
4784
- t = Math.max(0, Math.min(1, t));
4785
- return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
4786
- }
4787
- /**
4788
- * Convert a snap candidate into an `LinkEndpoint`. Anchor snap → named
4789
- * anchor ref; outline snap → outline ref with the sampled ratio. Falls
4790
- * back to a free point if the metadata isn't recognised.
4791
- */
4792
- const endpointFromSnap = (elementId, candidate, shape) => {
4793
- if (candidate.kind === "anchor") {
4794
- const ref = candidate.metadata?.ref;
4795
- if (ref)
4796
- return { kind: "anchor", elementId, anchor: ref };
4797
- }
4798
- if (candidate.kind === "outline" && typeof candidate.metadata?.ratio === "number") {
4799
- return { kind: "outline", elementId, ratio: candidate.metadata.ratio };
4800
- }
4801
- // Defensive fallback — should not happen with built-in contributors.
4802
- void shape;
4803
- return { kind: "point", position: candidate.snapped };
4804
- };
4805
- // `resizeFromHandle`, `applyResizeConstraints`, the four handle-
4806
- // quadrant predicates moved to `./editor/resize-helpers.ts` so
4807
- // they're shared between applies/resize and the container clamp.
4808
4170
  //# sourceMappingURL=editor.js.map