@oh-just-another/state 0.59.0 → 0.60.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 (149) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +2 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/actions/actionArrange.js +4 -4
  5. package/dist/actions/actionArrange.js.map +1 -1
  6. package/dist/actions/actionClipboard.d.ts.map +1 -1
  7. package/dist/actions/actionClipboard.js +5 -0
  8. package/dist/actions/actionClipboard.js.map +1 -1
  9. package/dist/actions/actionKeyboard.d.ts.map +1 -1
  10. package/dist/actions/actionKeyboard.js +77 -9
  11. package/dist/actions/actionKeyboard.js.map +1 -1
  12. package/dist/actions/actionMode.d.ts +4 -0
  13. package/dist/actions/actionMode.d.ts.map +1 -1
  14. package/dist/actions/actionMode.js +54 -0
  15. package/dist/actions/actionMode.js.map +1 -1
  16. package/dist/actions/actionSelection.d.ts.map +1 -1
  17. package/dist/actions/actionSelection.js +1 -0
  18. package/dist/actions/actionSelection.js.map +1 -1
  19. package/dist/actions/actionView.d.ts +6 -0
  20. package/dist/actions/actionView.d.ts.map +1 -1
  21. package/dist/actions/actionView.js +19 -1
  22. package/dist/actions/actionView.js.map +1 -1
  23. package/dist/actions/actionZoom.d.ts.map +1 -1
  24. package/dist/actions/actionZoom.js +5 -0
  25. package/dist/actions/actionZoom.js.map +1 -1
  26. package/dist/actions/index.d.ts +3 -3
  27. package/dist/actions/index.d.ts.map +1 -1
  28. package/dist/actions/index.js +3 -3
  29. package/dist/actions/index.js.map +1 -1
  30. package/dist/actions/registry.d.ts.map +1 -1
  31. package/dist/actions/registry.js +13 -0
  32. package/dist/actions/registry.js.map +1 -1
  33. package/dist/actions/types.d.ts +9 -0
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js.map +1 -1
  36. package/dist/clipboard.d.ts +0 -14
  37. package/dist/clipboard.d.ts.map +1 -1
  38. package/dist/clipboard.js +1 -1
  39. package/dist/clipboard.js.map +1 -1
  40. package/dist/constants.d.ts +160 -18
  41. package/dist/constants.d.ts.map +1 -1
  42. package/dist/constants.js +162 -2
  43. package/dist/constants.js.map +1 -1
  44. package/dist/dom-events.d.ts +1 -2
  45. package/dist/dom-events.d.ts.map +1 -1
  46. package/dist/dom-events.js.map +1 -1
  47. package/dist/editor/animation-scene.d.ts +15 -0
  48. package/dist/editor/animation-scene.d.ts.map +1 -1
  49. package/dist/editor/animation-scene.js +123 -0
  50. package/dist/editor/animation-scene.js.map +1 -1
  51. package/dist/editor/applies/create.d.ts +0 -6
  52. package/dist/editor/applies/create.d.ts.map +1 -1
  53. package/dist/editor/applies/create.js +1 -1
  54. package/dist/editor/applies/create.js.map +1 -1
  55. package/dist/editor/applies/link-move.d.ts +0 -8
  56. package/dist/editor/applies/link-move.d.ts.map +1 -1
  57. package/dist/editor/applies/link-move.js +1 -1
  58. package/dist/editor/applies/link-move.js.map +1 -1
  59. package/dist/editor/interaction-state.d.ts +184 -0
  60. package/dist/editor/interaction-state.d.ts.map +1 -0
  61. package/dist/editor/interaction-state.js +139 -0
  62. package/dist/editor/interaction-state.js.map +1 -0
  63. package/dist/editor/link-handle-drag.d.ts +152 -0
  64. package/dist/editor/link-handle-drag.d.ts.map +1 -0
  65. package/dist/editor/link-handle-drag.js +300 -0
  66. package/dist/editor/link-handle-drag.js.map +1 -0
  67. package/dist/editor/pointer-binding.d.ts +9 -11
  68. package/dist/editor/pointer-binding.d.ts.map +1 -1
  69. package/dist/editor/pointer-binding.js +1185 -901
  70. package/dist/editor/pointer-binding.js.map +1 -1
  71. package/dist/editor/public/brush.d.ts +31 -12
  72. package/dist/editor/public/brush.d.ts.map +1 -1
  73. package/dist/editor/public/brush.js +66 -17
  74. package/dist/editor/public/brush.js.map +1 -1
  75. package/dist/editor/public/cursor.d.ts +1 -15
  76. package/dist/editor/public/cursor.d.ts.map +1 -1
  77. package/dist/editor/public/cursor.js +14 -3
  78. package/dist/editor/public/cursor.js.map +1 -1
  79. package/dist/editor/public/eraser.d.ts +55 -0
  80. package/dist/editor/public/eraser.d.ts.map +1 -0
  81. package/dist/editor/public/eraser.js +65 -0
  82. package/dist/editor/public/eraser.js.map +1 -0
  83. package/dist/editor/public/laser.d.ts +38 -0
  84. package/dist/editor/public/laser.d.ts.map +1 -0
  85. package/dist/editor/public/laser.js +42 -0
  86. package/dist/editor/public/laser.js.map +1 -0
  87. package/dist/editor/public/placement.d.ts.map +1 -1
  88. package/dist/editor/public/placement.js +9 -1
  89. package/dist/editor/public/placement.js.map +1 -1
  90. package/dist/editor/public/selection-ops.d.ts +12 -0
  91. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  92. package/dist/editor/public/selection-ops.js +19 -1
  93. package/dist/editor/public/selection-ops.js.map +1 -1
  94. package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
  95. package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
  96. package/dist/editor/public/stroke-eraser-coverage.js +168 -0
  97. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
  98. package/dist/editor/public/stroke-eraser.d.ts +50 -0
  99. package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
  100. package/dist/editor/public/stroke-eraser.js +176 -0
  101. package/dist/editor/public/stroke-eraser.js.map +1 -0
  102. package/dist/editor/public/stroke-smoothing.d.ts +21 -0
  103. package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
  104. package/dist/editor/public/stroke-smoothing.js +37 -0
  105. package/dist/editor/public/stroke-smoothing.js.map +1 -0
  106. package/dist/editor/public/tool-ops.d.ts +130 -0
  107. package/dist/editor/public/tool-ops.d.ts.map +1 -0
  108. package/dist/editor/public/tool-ops.js +392 -0
  109. package/dist/editor/public/tool-ops.js.map +1 -0
  110. package/dist/editor/public/z-order.d.ts +1 -12
  111. package/dist/editor/public/z-order.d.ts.map +1 -1
  112. package/dist/editor/public/z-order.js +1 -1
  113. package/dist/editor/public/z-order.js.map +1 -1
  114. package/dist/editor/public/zoom-pan.d.ts +9 -0
  115. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  116. package/dist/editor/public/zoom-pan.js +31 -0
  117. package/dist/editor/public/zoom-pan.js.map +1 -1
  118. package/dist/editor/render-orchestrator.d.ts +177 -5
  119. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  120. package/dist/editor/render-orchestrator.js +517 -351
  121. package/dist/editor/render-orchestrator.js.map +1 -1
  122. package/dist/editor/text-edit.d.ts +136 -0
  123. package/dist/editor/text-edit.d.ts.map +1 -0
  124. package/dist/editor/text-edit.js +328 -0
  125. package/dist/editor/text-edit.js.map +1 -0
  126. package/dist/editor.d.ts +504 -239
  127. package/dist/editor.d.ts.map +1 -1
  128. package/dist/editor.js +1557 -706
  129. package/dist/editor.js.map +1 -1
  130. package/dist/handle.d.ts +0 -2
  131. package/dist/handle.d.ts.map +1 -1
  132. package/dist/handle.js +0 -6
  133. package/dist/handle.js.map +1 -1
  134. package/dist/index.d.ts +6 -1
  135. package/dist/index.d.ts.map +1 -1
  136. package/dist/index.js +7 -1
  137. package/dist/index.js.map +1 -1
  138. package/dist/modes.d.ts +15 -1
  139. package/dist/modes.d.ts.map +1 -1
  140. package/dist/modes.js.map +1 -1
  141. package/dist/overlay.d.ts +91 -2
  142. package/dist/overlay.d.ts.map +1 -1
  143. package/dist/overlay.js +450 -93
  144. package/dist/overlay.js.map +1 -1
  145. package/dist/search.d.ts +37 -0
  146. package/dist/search.d.ts.map +1 -0
  147. package/dist/search.js +45 -0
  148. package/dist/search.js.map +1 -0
  149. package/package.json +4 -4
package/dist/editor.js CHANGED
@@ -1,25 +1,24 @@
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, 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";
4
+ import { addElement, addLink, endpointElementId, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementLocalBounds, localToWorld, worldToLocal, isFrame, isGroup, isText, isImage, isBrush, brushBodyColor, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, SnapEngine, invert, updateLink, updateElement, isAnchorRef, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
5
5
  import { layerId as castLayerId, } from "@oh-just-another/types";
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";
6
+ import { bounds as B, matrix, vec2 } from "@oh-just-another/math";
7
+ import { onAnimationContentReady, setActiveRasterizer, setActiveTextShaper, ElementCache, } from "@oh-just-another/renderer-core";
8
8
  import { History, } from "@oh-just-another/history";
9
- import { DEFAULT_LINK_ROUTING, WAYPOINT_COLLAPSE_RADIUS } from "./constants.js";
9
+ import { DEFAULT_LINK_ROUTING } from "./constants.js";
10
10
  import { FileDropRegistry } from "./file-drop.js";
11
11
  import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
12
12
  import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
13
13
  import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
14
14
  import { AutoCompactScheduler } from "./auto-compact.js";
15
15
  import { AutoLayoutScheduler } from "./auto-layout-scheduler.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";
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, CROP_HANDLE_HIT_RADIUS, FLOWCHART_MAX_SIBLINGS, ERASER_TRAIL_TTL_MS, } from "./constants.js";
17
17
  import { HANDLE_HIT_SLOP } from "./handle.js";
18
18
  import { req } from "./util.js";
19
19
  import { interactionMachine, } from "./machine.js";
20
20
  import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
21
21
  import { AnimationController } from "./editor/animation.js";
22
- import { CaretBlinkController } from "./editor/caret-blink.js";
23
22
  import { GestureController } from "./editor/gesture-tx.js";
24
23
  import { GifPlaybackController } from "./editor/gif-playback.js";
25
24
  import * as animScene from "./editor/animation-scene.js";
@@ -30,25 +29,32 @@ import { PinchController } from "./editor/pinch.js";
30
29
  import { applyContainerDrop as applyContainerDropPure, clampContainerToChildren as clampContainerToChildrenPure, maybeGrowContainer as maybeGrowContainerPure, } from "./editor/container-ops.js";
31
30
  import { computeGroupResizePatches, computeElementResize, computeRotatedElementResize, computeTextResize, } from "./editor/applies/resize.js";
32
31
  import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
33
- import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, newBrushId, } from "./editor/public/brush.js";
32
+ import { InteractionState, } from "./editor/interaction-state.js";
33
+ import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, smoothBrushPoints, brushStyleFromSettings, DEFAULT_BRUSH_SETTINGS, newBrushId, } from "./editor/public/brush.js";
34
+ import { beginEraseStroke as beginEraseStrokePure, sampleErase as sampleErasePure, computeEraseCommit, } from "./editor/public/eraser.js";
35
+ import { computeEraseFromMasks, computeStrokeErasePreviewFromMasks, markErasedIntervals, } from "./editor/public/stroke-eraser.js";
36
+ import { coveredLength } from "./editor/public/stroke-eraser-coverage.js";
37
+ import { beginLaserStroke as beginLaserStrokePure, extendLaserStroke as extendLaserStrokePure, pruneLaserStrokes, } from "./editor/public/laser.js";
34
38
  import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
35
39
  import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, computeRenameLayer, computeToggleLayerLock, computeToggleLayerVisibility, newLayerId, } from "./editor/public/layers.js";
36
- import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, } from "./editor/public/zoom-pan.js";
40
+ import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, } from "./editor/public/zoom-pan.js";
37
41
  import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
38
- import { canBeginTextEdit } from "./editor/public/text-edit.js";
39
42
  import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
40
43
  import { computeCursor } from "./editor/public/cursor.js";
41
44
  import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
42
45
  import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
43
46
  import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
44
- import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
47
+ import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeApplyTextRunStyle, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
45
48
  import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
46
49
  import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
47
- import { renderEditor } from "./editor/render-orchestrator.js";
50
+ import { computeConvertType, computeCommitImageCrop, computeCropBodyPan, computeCropHandleDrag, computeSpawnConnectedNode, computeSpawnConnectedNodes, cropFullImageLocalRect, cropHandleWorldPoints, CROP_HANDLES, FULL_CROP, pickColorAt, } from "./editor/public/tool-ops.js";
51
+ import { renderEditor, } from "./editor/render-orchestrator.js";
52
+ import { TextEditController } from "./editor/text-edit.js";
53
+ import { LinkHandleDragController } from "./editor/link-handle-drag.js";
48
54
  import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
49
55
  import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
50
56
  import { selectByBounds as selectByBoundsPure, selectByBoundsLive as selectByBoundsLivePure, selectLinksByBoundsLive as selectLinksByBoundsLivePure, } from "./editor/applies/selection.js";
51
- import { computeLinkEndpointUpdate, computeLinkPreviewEndpoints, elbowSignature, } from "./editor/applies/edge.js";
57
+ import { computeLinkPreviewEndpoints, elbowSignature } from "./editor/applies/edge.js";
52
58
  import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, constrainDeltaToAxis, } from "./editor/applies/move.js";
53
59
  import { computeAlignPatches, computeDistributePatches, computeFlipPatches, computeRotatePatches, selectionCenter, } from "./editor/applies/arrange.js";
54
60
  import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
@@ -57,6 +63,28 @@ import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } fro
57
63
  import {} from "./overlay.js";
58
64
  import * as Selection from "./selection.js";
59
65
  import * as LinkSelection from "./link-selection.js";
66
+ /**
67
+ * Machine-emit types dropped while the editor is in read-only mode — every
68
+ * scene mutation the interaction machine can produce. Selection / lasso /
69
+ * preview-clear emits are absent so a viewer keeps click + marquee select.
70
+ */
71
+ const READ_ONLY_BLOCKED_EMITS = new Set([
72
+ "MOVE_SHAPE",
73
+ "RESIZE_GROUP",
74
+ "RESIZE_SHAPE",
75
+ "ROTATE",
76
+ "CREATE_SHAPE",
77
+ "CREATE_EDGE",
78
+ "MOVE_ANNOTATION",
79
+ "COMMIT_ANNOTATION_DRAG",
80
+ "UPDATE_EDGE_ENDPOINT",
81
+ "UPDATE_EDGE_ENDPOINT_PREVIEW",
82
+ "DRAW_EDGE_PREVIEW",
83
+ ]);
84
+ /** Shared empty id set — returned by `pendingErase` when no eraser stroke runs. */
85
+ const EMPTY_ELEMENT_SET = Object.freeze(new Set());
86
+ /** Monotonic wall-clock in ms, matching the domain used by the overlay fade. */
87
+ const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
60
88
  export class Editor {
61
89
  host;
62
90
  mainTarget;
@@ -70,6 +98,14 @@ export class Editor {
70
98
  * View-only — never persisted or recorded in history.
71
99
  */
72
100
  debugHitZones = false;
101
+ /**
102
+ * Read-only / view mode. When true the pointer paths that create,
103
+ * move, resize, rotate or delete are gated (pan / zoom / select stay
104
+ * live) and the action registry only runs actions flagged
105
+ * `viewMode`. View-only — never persisted or recorded in history.
106
+ * Read via {@link readOnly}; flip via {@link setReadOnly}.
107
+ */
108
+ _readOnly = false;
73
109
  actor;
74
110
  listeners = new Set();
75
111
  /**
@@ -93,45 +129,55 @@ export class Editor {
93
129
  _scene;
94
130
  _selection = Selection.EMPTY;
95
131
  /**
96
- * Snapshot of an in-progress annotation drag (press on pin → move
97
- * pointer → release). `originPosition` is the annotation's stored
98
- * position at press time; per-move handler computes a delta from
99
- * the current pointer in world space and writes it back.
132
+ * Ephemeral interaction / gesture state (previews, gesture origins,
133
+ * transient modifiers). Single source of truth for the short-lived fields
134
+ * the pointer handlers, render orchestrator and container-ops read/write
135
+ * while a gesture is in flight. The public fields below delegate to it so
136
+ * external writers keep referencing `editor.<field>` unchanged.
100
137
  */
101
- annotationDrag = null;
138
+ interaction = new InteractionState();
139
+ /** Snapshot of an in-progress annotation-pin drag. */
140
+ get annotationDrag() {
141
+ return this.interaction.annotationDrag;
142
+ }
143
+ set annotationDrag(v) {
144
+ this.interaction.annotationDrag = v;
145
+ }
102
146
  /** Live preview while drawing a new shape; null when not drawing. */
103
- drawingPreview = null;
104
- edgePreview = null;
105
- /**
106
- * Active "drag a link from a start-anchor" gesture. Set when a
107
- * press lands on one of the selected element's link-start dots; lets
108
- * the user draw a link straight from the dot without switching to the
109
- * draw-edge tool. `fromWorld` is the true anchor world point (the link
110
- * origin, un-offset); `origin` is the press point (for the drag
111
- * threshold). Read by the pointer handlers (drive preview / commit on
112
- * up) and the render orchestrator (keep the source's start dots visible
113
- * during the drag). Null when no such drag is in flight. */
114
- linkDragFromAnchor = null;
115
- /**
116
- * Element being hovered while draw-edge mode is active. Drives the port-
117
- * overlay render so the user sees attachment points. `null` outside
118
- * draw-edge mode or when the pointer is over empty canvas.
119
- */
120
- hoveredLinkTarget = null;
121
- /**
122
- * Last idle cursor position (world) in select mode — the overlay grows the
123
- * SINGLE selected element's link-start dot nearest it
124
- * (`ANCHOR_DOT_HOVER_GROW_RADIUS`). Reset to null on press / gesture.
125
- */
126
- hoverCursorWorld = null;
127
- /**
128
- * When a link is dropped on empty canvas, the edge is created with a
129
- * free `point` end and this records where, so the host can pop a
130
- * mini shape-picker at that spot (standard). Picking a shape re-points the
131
- * end to the new element; dismissing (Esc / click-away) leaves the free
132
- * end on the canvas. `null` when no menu is pending.
133
- */
134
- pendingLinkDropMenu = null;
147
+ get drawingPreview() {
148
+ return this.interaction.drawingPreview;
149
+ }
150
+ set drawingPreview(v) {
151
+ this.interaction.drawingPreview = v;
152
+ }
153
+ /** Live preview of an edge being drawn. */
154
+ get edgePreview() {
155
+ return this.interaction.edgePreview;
156
+ }
157
+ set edgePreview(v) {
158
+ this.interaction.edgePreview = v;
159
+ }
160
+ /** Active "drag a link from a start-anchor" gesture. */
161
+ get linkDragFromAnchor() {
162
+ return this.interaction.linkDragFromAnchor;
163
+ }
164
+ set linkDragFromAnchor(v) {
165
+ this.interaction.linkDragFromAnchor = v;
166
+ }
167
+ /** Element hovered while draw-edge mode is active (drives the port overlay). */
168
+ get hoveredLinkTarget() {
169
+ return this.interaction.hoveredLinkTarget;
170
+ }
171
+ set hoveredLinkTarget(v) {
172
+ this.interaction.hoveredLinkTarget = v;
173
+ }
174
+ /** Last idle cursor position (world) in select mode grows the nearest dot. */
175
+ get hoverCursorWorld() {
176
+ return this.interaction.hoverCursorWorld;
177
+ }
178
+ set hoverCursorWorld(v) {
179
+ this.interaction.hoverCursorWorld = v;
180
+ }
135
181
  /**
136
182
  * Currently selected links (connectors). Links are first-class members
137
183
  * of the selection: they coexist with selected elements, join Cmd+A and
@@ -157,69 +203,67 @@ export class Editor {
157
203
  /**
158
204
  * Mid-drag preview state when the user is dragging an edge endpoint.
159
205
  * Drawn as an overlay line + handle dot so the user sees the target.
206
+ * State lives in `LinkHandleDragController`; this is a delegate.
160
207
  */
161
- linkEndpointDrag = null;
208
+ get linkEndpointDrag() {
209
+ return this.linkHandles.endpointDrag;
210
+ }
162
211
  /**
163
212
  * Host-managed waypoint (bend-point) drag of the selected link. `index`
164
213
  * is the position in `edge.waypoints`. `pendingInsert` means the gesture
165
214
  * began on a segment midpoint and will splice a new waypoint on the
166
215
  * first move (so a no-move click adds nothing). Live-mutated through the
167
216
  * gesture transaction → one undo step per drag.
217
+ * State lives in `LinkHandleDragController`; this is a delegate.
168
218
  */
169
- linkWaypointDrag = null;
219
+ get linkWaypointDrag() {
220
+ return this.linkHandles.waypointDrag;
221
+ }
170
222
  /**
171
- * Host-managed elbow segment drag. `index` is the segment in the routed
172
- * chain `[from, ...routedPoints, to]`; `axis` is its orientation. Dragging
173
- * pins the segment's perpendicular coordinate into `Link.fixedSegments`;
174
- * the reroute pass re-flows the rest. One undo step via the gesture tx.
223
+ * Host-managed elbow segment drag. `axis` is the segment's orientation.
224
+ * Dragging pins the segment's perpendicular coordinate into
225
+ * `Link.fixedSegments`; the reroute pass re-flows the rest. One undo
226
+ * step via the gesture tx.
227
+ * State lives in `LinkHandleDragController`; this is a delegate.
175
228
  */
176
- linkSegmentDrag = null;
229
+ get linkSegmentDrag() {
230
+ return this.linkHandles.segmentDrag;
231
+ }
177
232
  /** Live lasso bounds during a rubber-band select gesture. */
178
- lassoPreview = null;
179
- /**
180
- * Selection captured at lasso-press time. Used to compute the live
181
- * preview correctly: in `replace` mode the lasso starts from empty
182
- * each frame; in `add` mode it starts from this snapshot so shapes
183
- * the user already had selected don't blink out and back.
184
- */
185
- lassoBaseSelection = null;
186
- /** Link-selection counterpart of `lassoBaseSelection` for the marquee. */
187
- lassoBaseLinks = null;
188
- /**
189
- * Snapshot of every selected shape's `position` at press-down. Used to
190
- * translate the whole group additively during a multi-shape drag. The
191
- * machine still emits per-shape MOVE_SHAPE the editor intercepts and
192
- * fans out when this map is populated.
193
- */
194
- groupMoveOrigin = null;
195
- /**
196
- * Press-time snapshot of connectors that must follow a multi-element
197
- * drag rigidly — both endpoints bound to moved elements, carrying
198
- * absolute geometry (waypoints / fixedSegments / routedPoints). Each
199
- * frame translates from these originals so the shift never compounds.
200
- * Cleared on gesture commit / cancel alongside `groupMoveOrigin`.
201
- */
202
- groupLinkMoveOrigin = null;
203
- /**
204
- * Per-shape snapshot for a group-resize gesture — `bounds` is the
205
- * shape's world AABB at press-down. Editor scales the relative
206
- * position / size against the combined bounds delta each frame.
207
- */
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;
216
- /**
217
- * Pristine shape snapshot for a single-shape text resize, captured on
218
- * the gesture's first tick. Font scaling is computed against this base
219
- * so it never compounds across pointermove ticks. Cleared on gesture
220
- * end (commit / cancel).
221
- */
222
- _resizeOriginElement = null;
233
+ get lassoPreview() {
234
+ return this.interaction.lassoPreview;
235
+ }
236
+ set lassoPreview(v) {
237
+ this.interaction.lassoPreview = v;
238
+ }
239
+ /** Snapshot of every selected shape's `position` at press-down (multi-drag). */
240
+ get groupMoveOrigin() {
241
+ return this.interaction.groupMoveOrigin;
242
+ }
243
+ set groupMoveOrigin(v) {
244
+ this.interaction.groupMoveOrigin = v;
245
+ }
246
+ /** Press-time snapshot of connectors that follow a multi-element drag rigidly. */
247
+ get groupLinkMoveOrigin() {
248
+ return this.interaction.groupLinkMoveOrigin;
249
+ }
250
+ set groupLinkMoveOrigin(v) {
251
+ this.interaction.groupLinkMoveOrigin = v;
252
+ }
253
+ /** Per-shape snapshot for a group-resize gesture. */
254
+ get groupResizeOrigin() {
255
+ return this.interaction.groupResizeOrigin;
256
+ }
257
+ set groupResizeOrigin(v) {
258
+ this.interaction.groupResizeOrigin = v;
259
+ }
260
+ /** Press-time snapshot for a rotate gesture. */
261
+ get rotateGestureOrigin() {
262
+ return this.interaction.rotateGestureOrigin;
263
+ }
264
+ set rotateGestureOrigin(v) {
265
+ this.interaction.rotateGestureOrigin = v;
266
+ }
223
267
  /**
224
268
  * Active layer — new shapes created via `addElement` / `applyCreate` land
225
269
  * here when their input doesn't specify a `layerId`. Defaults to the
@@ -241,21 +285,6 @@ export class Editor {
241
285
  ]);
242
286
  /** Snap threshold in world units. */
243
287
  snapThreshold = DEFAULT_SNAP_THRESHOLD;
244
- /**
245
- * Transient flag set by the host while a snap-suppress modifier
246
- * (Cmd / Ctrl) is held during a drag — lets the user pull a shape off
247
- * the grid for one gesture without toggling snap off. Read by the
248
- * move / resize / create wrappers; never persisted.
249
- */
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
288
  /**
260
289
  * In-editor style memory for copy-style / paste-style. Holds the visual
261
290
  * `style` (fill / stroke / dash / …) captured from a shape; `null` until a
@@ -283,36 +312,64 @@ export class Editor {
283
312
  * escape, click outside the group's descendants, or `cancelInteraction`.
284
313
  */
285
314
  _enteredGroup = null;
286
- /**
287
- * Double-click detection state. Updated on every non-drag pointer
288
- * up; the next pointer-up within `DOUBLE_CLICK_MS` and within
289
- * `DOUBLE_CLICK_TOLERANCE_PX` of `lastClickWorldPoint` counts as a
290
- * double-click. Used to trigger group drill-down (enter isolation).
291
- */
292
- lastClickAt = 0;
293
- lastClickWorldPoint = null;
294
- /**
295
- * Separate double-click tracker for link edit handles (waypoint /
296
- * segment). Kept apart from `lastClickAt` because a handle press
297
- * returns early in `onDown` (begin-drag) and never reaches the up-side
298
- * double-click path that updates `lastClickAt`. Updated by
299
- * `isHandleDoubleClick` on each handle press.
300
- */
301
- lastHandleClickAt = 0;
302
- lastHandleClickWorld = null;
303
315
  /**
304
316
  * In-progress brush stroke. Hosts push points via
305
317
  * `extendBrushStroke`; the overlay reads it through
306
318
  * `pendingBrushStroke` to draw a live preview.
307
319
  */
308
- brushStroke = null;
320
+ get brushStroke() {
321
+ return this.interaction.brushStroke;
322
+ }
323
+ set brushStroke(v) {
324
+ this.interaction.brushStroke = v;
325
+ }
326
+ /**
327
+ * The in-progress brush stroke with its captured vertices Catmull-Rom-smoothed
328
+ * for the LIVE overlay preview — the SAME resampler `commitBrushStroke` applies
329
+ * on release (see {@link smoothBrushPoints}), so the stroke reads smooth while
330
+ * drawn instead of snapping from an angular polyline to a curve on release. A
331
+ * fresh object each call (points diverge from `brushStroke.points`), so the
332
+ * overlay memo repaints every move that grows the stroke.
333
+ */
334
+ get brushPreviewStroke() {
335
+ const s = this.interaction.brushStroke;
336
+ if (!s)
337
+ return null;
338
+ const style = brushStyleFromSettings(this._brushSettings);
339
+ return {
340
+ origin: s.origin,
341
+ points: smoothBrushPoints(s.points),
342
+ fill: brushBodyColor(style),
343
+ opacity: style.opacity ?? 1,
344
+ };
345
+ }
346
+ /** In-progress eraser stroke (pending-delete set), or null between strokes. */
347
+ get eraseStroke() {
348
+ return this.interaction.eraseStroke;
349
+ }
350
+ set eraseStroke(v) {
351
+ this.interaction.eraseStroke = v;
352
+ }
353
+ /** Ids swept by the current eraser stroke — previewed dimmed, deleted on release. */
354
+ get pendingErase() {
355
+ return this.interaction.eraseStroke?.pending ?? EMPTY_ELEMENT_SET;
356
+ }
357
+ /** Live laser-pointer trails (ephemeral, fading). Empty when none active. */
358
+ get laserStrokes() {
359
+ return this.interaction.laserStrokes;
360
+ }
309
361
  /**
310
362
  * Last world-space pointer position observed by the host's onMove
311
363
  * handler. `paste()` uses it as the default drop target so a fresh
312
364
  * paste lands under the cursor instead of overlapping the originals.
313
365
  * `null` until the pointer first enters the host.
314
366
  */
315
- lastPointerWorld = null;
367
+ get lastPointerWorld() {
368
+ return this.interaction.lastPointerWorld;
369
+ }
370
+ set lastPointerWorld(v) {
371
+ this.interaction.lastPointerWorld = v;
372
+ }
316
373
  /** Host-registered custom cursor images per role (see `setCursorOverride`). */
317
374
  cursorOverrides = new Map();
318
375
  /**
@@ -331,6 +388,23 @@ export class Editor {
331
388
  * pass would never visibly apply.
332
389
  */
333
390
  lastRenderedEnteredGroup = null;
391
+ /**
392
+ * Whether the last paint had eraser-dim active — paired with
393
+ * `lastRenderedScene` like {@link lastRenderedEnteredGroup}. When an eraser
394
+ * stroke ENDS by cancel (Esc), the marked shapes un-dim without a scene
395
+ * change, so the dirty-rect diff is empty and the dim would linger on screen;
396
+ * this lets that active→inactive transition force one full repaint.
397
+ */
398
+ lastRenderedEraseActive = false;
399
+ /**
400
+ * Set whenever an eraser move actually CHANGES the marked / cut set (a new
401
+ * shape marked, un-marked, or a brush point cut). Gates the forced full
402
+ * repaint during erasing: only the frames that change the preview repaint the
403
+ * whole scene; a slowly-moving or stopped cursor over already-covered area
404
+ * skips the expensive main pass (only the overlay cursor / trail refresh).
405
+ * Cleared after each paint.
406
+ */
407
+ eraseDirty = false;
334
408
  /**
335
409
  * Fractional-order compaction scheduler (microtask-coalesced).
336
410
  * Triggered from every `notify()`; only does real work when at
@@ -375,7 +449,12 @@ export class Editor {
375
449
  * gestures, set in onDown when press lands on a shape and cleared
376
450
  * in onUp / cancel.
377
451
  */
378
- dragElementId = null;
452
+ get dragElementId() {
453
+ return this.interaction.dragElementId;
454
+ }
455
+ set dragElementId(v) {
456
+ this.interaction.dragElementId = v;
457
+ }
379
458
  /**
380
459
  * Element that the current press added to the selection additively
381
460
  * (shift / meta click on an unselected shape). The press promotes it
@@ -383,14 +462,24 @@ export class Editor {
383
462
  * otherwise `SELECT_TOGGLE` it straight back off, so it consults this
384
463
  * to skip that redundant toggle. Reset at every press-down.
385
464
  */
386
- additivePressAdded = null;
465
+ get additivePressAdded() {
466
+ return this.interaction.additivePressAdded;
467
+ }
468
+ set additivePressAdded(v) {
469
+ this.interaction.additivePressAdded = v;
470
+ }
387
471
  /**
388
472
  * Live container highlight: the container shape the dragged item is
389
473
  * currently hovering over. Drawn by the overlay as a dashed
390
474
  * accent rect on the container's drop-zone so the user sees where the
391
475
  * shape will land after release.
392
476
  */
393
- containerHover = null;
477
+ get containerHover() {
478
+ return this.interaction.containerHover;
479
+ }
480
+ set containerHover(v) {
481
+ this.interaction.containerHover = v;
482
+ }
394
483
  /**
395
484
  * Remote peer cursors / selections, pushed in by the host (typically
396
485
  * a `bindAwareness(editor, awareness)` helper in `@collab`). The
@@ -410,7 +499,9 @@ export class Editor {
410
499
  * two or more entries we enter a pinch / pan gesture and bypass the
411
500
  * interaction machine — `pinchOrigin` holds the baseline.
412
501
  */
413
- activePointers = new Map();
502
+ get activePointers() {
503
+ return this.interaction.activePointers;
504
+ }
414
505
  /**
415
506
  * One-finger-pan candidate: set at pointer-down when a TOUCH press lands
416
507
  * on empty canvas in select mode. A tap (no movement) still falls through
@@ -418,7 +509,12 @@ export class Editor {
418
509
  * this to a real pan instead of a marquee lasso (mobile convention).
419
510
  * Screen-space origin point.
420
511
  */
421
- touchPanCandidate = null;
512
+ get touchPanCandidate() {
513
+ return this.interaction.touchPanCandidate;
514
+ }
515
+ set touchPanCandidate(v) {
516
+ this.interaction.touchPanCandidate = v;
517
+ }
422
518
  // Pinch gesture state lives in PinchController; `pinch.isActive()`
423
519
  // reports whether a two-finger gesture is in flight.
424
520
  pinch;
@@ -430,7 +526,12 @@ export class Editor {
430
526
  * "grab" / "grabbing". Wires a window-level keydown/keyup listener
431
527
  * in `bindPointerEvents`.
432
528
  */
433
- spaceHeld = false;
529
+ get spaceHeld() {
530
+ return this.interaction.spaceHeld;
531
+ }
532
+ set spaceHeld(v) {
533
+ this.interaction.spaceHeld = v;
534
+ }
434
535
  /**
435
536
  * Host-supplied tile compositor — when set (via
436
537
  * `EditorOptions.useTileCache` + `tileCompose`), the per-frame
@@ -477,14 +578,24 @@ export class Editor {
477
578
  * only treat right-click releases as potential context-menu
478
579
  * triggers (Space + left-drag never opens a menu).
479
580
  */
480
- panGesture = null;
581
+ get panGesture() {
582
+ return this.interaction.panGesture;
583
+ }
584
+ set panGesture(v) {
585
+ this.interaction.panGesture = v;
586
+ }
481
587
  /**
482
588
  * Set on a right-click pointerdown so the upcoming native
483
589
  * `contextmenu` event can be unconditionally preventDefault'ed
484
590
  * (the gesture decides whether to fire the menu manually on
485
591
  * pointerup based on whether the user dragged).
486
592
  */
487
- suppressNextContextMenu = false;
593
+ get suppressNextContextMenu() {
594
+ return this.interaction.suppressNextContextMenu;
595
+ }
596
+ set suppressNextContextMenu(v) {
597
+ this.interaction.suppressNextContextMenu = v;
598
+ }
488
599
  /**
489
600
  * Long-press tracking. Starts on `pointerdown`; cancelled on
490
601
  * `pointermove > LONG_PRESS_MAX_MOVEMENT_PX` or `pointerup` before
@@ -532,6 +643,18 @@ export class Editor {
532
643
  * built lazily below.
533
644
  */
534
645
  gestures;
646
+ /**
647
+ * Owns the inline text-edit session (edited shape, pending creation,
648
+ * origin snapshot, live selection, drag anchor and caret blink).
649
+ * Editor keeps thin delegate wrappers so the public API is unchanged.
650
+ */
651
+ textEdit;
652
+ /**
653
+ * Owns the link edit-handle drags (waypoint / segment / endpoint) and
654
+ * the handle double-click detector. Editor keeps thin delegate
655
+ * wrappers so the public API is unchanged.
656
+ */
657
+ linkHandles;
535
658
  constructor(options) {
536
659
  this.host = options.host;
537
660
  this.mainTarget = options.mainTarget;
@@ -542,6 +665,55 @@ export class Editor {
542
665
  this._history = isHistoryProvider(options.history)
543
666
  ? options.history
544
667
  : new History(options.history ?? {});
668
+ this.tileComposeFn =
669
+ options.useTileCache === true && options.tileCompose ? options.tileCompose : null;
670
+ this.initControllers();
671
+ this.initGlobalHooks(options);
672
+ this.initInputMode(options);
673
+ this.initActor(options);
674
+ this.unbind = this.bindPointerEvents();
675
+ // Pause animation playback when the tab / window is hidden (browsers
676
+ // throttle rAF to ~1fps in background but don't stop it; an explicit
677
+ // stop saves the decode + render entirely). Resume when visible again,
678
+ // viewport permitting.
679
+ this.animation.attach();
680
+ // Restore GIF/video bytes onto animated image shapes loaded from
681
+ // an initial scene (e.g. localStorage), then arm the tick so the
682
+ // animation plays from first paint.
683
+ animScene.rehydrateAnimatedImages(this);
684
+ // Rebuild live handles for static images restored from storage — their
685
+ // `metadata.image` didn't survive serialisation and `src` is a dead
686
+ // `blob:` URL, so decode the bytes back from `Scene.files`. Async;
687
+ // repaints itself when the decode lands.
688
+ void animScene.rehydrateStaticImages(this);
689
+ this.maybeAnimate();
690
+ // An animated adapter (GIF) decodes asynchronously; when a decode
691
+ // completes it nudges us here. Re-render so a PAUSED animated shape
692
+ // (reduced-motion / auto-stopped / frozen) — which has no tick to
693
+ // pick the frames up — paints its decoded frame after reload.
694
+ this.animationContentOff = onAnimationContentReady(() => {
695
+ this.scheduleRender();
696
+ });
697
+ // First paint — synchronous so the canvas isn't blank for one
698
+ // frame on mount. Hosts that mount + immediately read the
699
+ // bitmap also get a consistent first frame.
700
+ this.forceRender();
701
+ // Prime the typed-event cache with the editor's initial state so
702
+ // the *first* user-driven update only emits on a real flip.
703
+ // Without this, an `editor.on("mode", fn)` listener installed
704
+ // before any change would fire on the very next `setMode(current)`
705
+ // call because every cached slice would still be `null`.
706
+ primeEventCache(this.eventCache, this.observableSnapshot());
707
+ }
708
+ /**
709
+ * Build the interaction controllers (gestures, text edit, link-handle
710
+ * drag, long-press, pinch) and the container-ops bridge. Each wires to a
711
+ * narrow getter/setter surface over the editor's mutable fields, so the
712
+ * controllers live in their own modules without importing Editor. The
713
+ * getters/setters in the object literals rebind `this`, so a single
714
+ * `self` alias captures the Editor reference for all of them.
715
+ */
716
+ initControllers() {
545
717
  // Build the gesture controller against a narrow getter/setter
546
718
  // bridge to the editor's mutable state. The bridge is a thin
547
719
  // adapter — keeps `gestureTx`/`dragElementId` etc. as `private`
@@ -603,8 +775,98 @@ export class Editor {
603
775
  self.notify();
604
776
  },
605
777
  });
606
- this.tileComposeFn =
607
- options.useTileCache === true && options.tileCompose ? options.tileCompose : null;
778
+ // Same bridge pattern for the text-edit controller: scene access goes
779
+ // through get/set so live edits replace `_scene` without history.
780
+ this.textEdit = new TextEditController({
781
+ get scene() {
782
+ return self._scene;
783
+ },
784
+ set scene(s) {
785
+ self._scene = s;
786
+ },
787
+ pushHistory: (patch) => {
788
+ self._history.push(patch);
789
+ },
790
+ notify: () => {
791
+ self.notify();
792
+ },
793
+ isLayerLocked: (id) => self.isLayerLocked(id),
794
+ clearSelectionFor: (id) => {
795
+ if (self._selection.has(id))
796
+ self._selection = Selection.EMPTY;
797
+ },
798
+ mainTarget: this.mainTarget,
799
+ });
800
+ // Same bridge pattern for the link handle-drag controller.
801
+ this.linkHandles = new LinkHandleDragController({
802
+ get scene() {
803
+ return self._scene;
804
+ },
805
+ set scene(s) {
806
+ self._scene = s;
807
+ },
808
+ pushHistory: (patch) => {
809
+ self._history.push(patch);
810
+ },
811
+ recordGesturePatch: (patch) => {
812
+ self.recordGesturePatch(patch);
813
+ },
814
+ commitGesture: () => {
815
+ self.commitGesture();
816
+ },
817
+ cancelGesture: () => {
818
+ self.cancelGesture();
819
+ },
820
+ hasGestureTx: () => self.gestureTx !== null,
821
+ notify: () => {
822
+ self.notify();
823
+ },
824
+ linkAttachTargetAt: (worldPoint) => self.linkAttachTargetAt(worldPoint),
825
+ snapLinkEndpoint: (targetId, worldPoint) => self.snapLinkEndpoint(targetId, worldPoint),
826
+ updateHoveredLinkTarget: (worldPoint) => {
827
+ self.updateHoveredLinkTarget(worldPoint);
828
+ },
829
+ clearHoveredLinkTarget: () => {
830
+ self.hoveredLinkTarget = null;
831
+ },
832
+ });
833
+ // Long-press controller — fired on touch-hold; fans out to
834
+ // host-registered listeners (mobile alt to right-click).
835
+ this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
836
+ for (const fn of this.longPressListeners)
837
+ fn(payload);
838
+ });
839
+ // Pinch gesture controller — two-finger pan + zoom. Hooks into
840
+ // the editor's own zoomAt / panBy / screenToWorld.
841
+ this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
842
+ this.zoomAt(factor, anchorWorld);
843
+ }, (delta) => {
844
+ this.panBy(delta);
845
+ });
846
+ // Bridge for the container-ops helpers — narrow surface that the
847
+ // pure functions call back into.
848
+ this.containerOpsRef = {
849
+ get scene() {
850
+ return self._scene;
851
+ },
852
+ get dragElementId() {
853
+ return self.dragElementId;
854
+ },
855
+ get containerHover() {
856
+ return self.containerHover;
857
+ },
858
+ applyPatch(patch, nextScene) {
859
+ self._scene = nextScene;
860
+ self.beginOrAttachGesture().add(patch);
861
+ },
862
+ };
863
+ }
864
+ /**
865
+ * Install process-global hooks the host opted into: a custom text shaper
866
+ * and rasterizer for the WebGL2 backend, plus the scene text measurer that
867
+ * routes through the renderer's own metrics so selection boxes hug text.
868
+ */
869
+ initGlobalHooks(options) {
608
870
  // If the host plugged a TextShaper, install it process-globally so the
609
871
  // built-in text renderer's wrap path uses it instead of
610
872
  // Canvas2D.measureText. Hosts that don't care leave the field unset and
@@ -629,6 +891,12 @@ export class Editor {
629
891
  });
630
892
  return this.mainTarget.measureText(text).width;
631
893
  });
894
+ }
895
+ /**
896
+ * Resolve the input mode (`touch` vs `mouse`, `auto` reads the coarse-
897
+ * pointer media query) and the derived hit slops / thresholds once.
898
+ */
899
+ initInputMode(options) {
632
900
  // Resolve input mode + derived hit slops once. `auto` reads
633
901
  // `matchMedia('(pointer: coarse)')` when available; SSR falls
634
902
  // back to `mouse`.
@@ -656,6 +924,13 @@ export class Editor {
656
924
  this.inputMode === "touch" ? TOUCH_ANCHOR_START_HIT_SLOP : ANCHOR_START_HIT_SLOP;
657
925
  this.anchorClickRadius =
658
926
  this.inputMode === "touch" ? TOUCH_ANCHOR_DOT_CLICK_RADIUS : ANCHOR_DOT_CLICK_RADIUS;
927
+ }
928
+ /**
929
+ * Create and start the interaction state-machine actor, wire its render /
930
+ * emit subscriptions, register the built-in file-drop handlers, and apply
931
+ * the initial mode.
932
+ */
933
+ initActor(options) {
659
934
  this.actor = createActor(interactionMachine);
660
935
  this.actor.subscribe({
661
936
  next: () => {
@@ -672,80 +947,55 @@ export class Editor {
672
947
  // *after* and only fire for files we don't already handle.
673
948
  this.fileDropRegistry.register(imageFileDropHandler);
674
949
  this.fileDropRegistry.register(videoFileDropHandler);
950
+ this._readOnly = options.readOnly ?? false;
675
951
  if (options.initialMode) {
676
952
  this.actor.send({ type: "SET_MODE", mode: options.initialMode });
677
953
  }
678
- // Long-press controller — fired on touch-hold; fans out to
679
- // host-registered listeners (mobile alt to right-click).
680
- this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
681
- for (const fn of this.longPressListeners)
682
- fn(payload);
683
- });
684
- // Pinch gesture controller — two-finger pan + zoom. Hooks into
685
- // the editor's own zoomAt / panBy / screenToWorld.
686
- this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
687
- this.zoomAt(factor, anchorWorld);
688
- }, (delta) => {
689
- this.panBy(delta);
690
- });
691
- // Bridge for the container-ops helpers — narrow surface that the
692
- // pure functions call back into.
693
- // eslint-disable-next-line @typescript-eslint/no-this-alias -- bridge literal rebinds `this`; alias keeps Editor reference
694
- const self2 = this;
695
- this.containerOpsRef = {
696
- get scene() {
697
- return self2._scene;
698
- },
699
- get dragElementId() {
700
- return self2.dragElementId;
701
- },
702
- get containerHover() {
703
- return self2.containerHover;
704
- },
705
- applyPatch(patch, nextScene) {
706
- self2._scene = nextScene;
707
- self2.beginOrAttachGesture().add(patch);
708
- },
709
- };
710
- this.unbind = this.bindPointerEvents();
711
- // Pause animation playback when the tab / window is hidden (browsers
712
- // throttle rAF to ~1fps in background but don't stop it; an explicit
713
- // stop saves the decode + render entirely). Resume when visible again,
714
- // viewport permitting.
715
- this.animation.attach();
716
- // Restore GIF/video bytes onto animated image shapes loaded from
717
- // an initial scene (e.g. localStorage), then arm the tick so the
718
- // animation plays from first paint.
719
- animScene.rehydrateAnimatedImages(this);
720
- this.maybeAnimate();
721
- // An animated adapter (GIF) decodes asynchronously; when a decode
722
- // completes it nudges us here. Re-render so a PAUSED animated shape
723
- // (reduced-motion / auto-stopped / frozen) — which has no tick to
724
- // pick the frames up — paints its decoded frame after reload.
725
- this.animationContentOff = onAnimationContentReady(() => {
726
- this.scheduleRender();
727
- });
728
- // First paint — synchronous so the canvas isn't blank for one
729
- // frame on mount. Hosts that mount + immediately read the
730
- // bitmap also get a consistent first frame.
731
- this.forceRender();
732
- // Prime the typed-event cache with the editor's initial state so
733
- // the *first* user-driven update only emits on a real flip.
734
- // Without this, an `editor.on("mode", fn)` listener installed
735
- // before any change would fire on the very next `setMode(current)`
736
- // call because every cached slice would still be `null`.
737
- primeEventCache(this.eventCache, this.observableSnapshot());
738
954
  }
739
- /** Snapshot used by event-fanout. Kept private — internal API. */
955
+ /**
956
+ * Last {@link observableSnapshot} object, reused while none of its slices
957
+ * have flipped. `null` until the first snapshot is built.
958
+ */
959
+ snapshotCache = null;
960
+ /**
961
+ * Snapshot used by event-fanout. Kept private — internal API.
962
+ *
963
+ * Memoized by slice identity: `notify()` fires on many mutations that touch
964
+ * no observable slice (annotation focus, cursor pushes, viewport-only re-arm),
965
+ * so rebuilding the object every call is pure churn on the hot drag path.
966
+ * We reuse the cached object whenever all six slices compare equal (refs for
967
+ * mode/selection/selectedLinks/scene — scene uses structural sharing so a new
968
+ * ref iff something changed — plus the two history booleans), and only
969
+ * allocate a fresh one on a real flip. `fanOutEvents` sees identical values
970
+ * either way, so emitted events are unchanged.
971
+ */
740
972
  observableSnapshot() {
741
- return {
742
- mode: this.mode,
743
- selection: this._selection,
744
- selectedLinks: this._selectedLinks,
745
- scene: this._scene,
746
- canUndo: this.canUndo,
747
- canRedo: this.canRedo,
973
+ const mode = this.mode;
974
+ const selection = this._selection;
975
+ const selectedLinks = this._selectedLinks;
976
+ const scene = this._scene;
977
+ const canUndo = this.canUndo;
978
+ const canRedo = this.canRedo;
979
+ const cached = this.snapshotCache;
980
+ if (cached !== null &&
981
+ cached.mode === mode &&
982
+ cached.selection === selection &&
983
+ cached.selectedLinks === selectedLinks &&
984
+ cached.scene === scene &&
985
+ cached.canUndo === canUndo &&
986
+ cached.canRedo === canRedo) {
987
+ return cached;
988
+ }
989
+ const snapshot = {
990
+ mode,
991
+ selection,
992
+ selectedLinks,
993
+ scene,
994
+ canUndo,
995
+ canRedo,
748
996
  };
997
+ this.snapshotCache = snapshot;
998
+ return snapshot;
749
999
  }
750
1000
  // --- Public state ---
751
1001
  get scene() {
@@ -920,6 +1170,29 @@ export class Editor {
920
1170
  this.debugHitZones = on;
921
1171
  this.scheduleRender();
922
1172
  }
1173
+ /**
1174
+ * Read-only / view mode flag. `true` gates pointer edits and
1175
+ * non-`viewMode` actions while leaving pan / zoom / select live.
1176
+ */
1177
+ get readOnly() {
1178
+ return this._readOnly;
1179
+ }
1180
+ /**
1181
+ * Enter / leave read-only (view) mode. Notifies subscribers so the UI
1182
+ * can re-render disabled chrome, and repaints (no visual diff today, but
1183
+ * keeps the contract symmetric with other view toggles). Idempotent.
1184
+ */
1185
+ setReadOnly(on) {
1186
+ if (this._readOnly === on)
1187
+ return;
1188
+ this._readOnly = on;
1189
+ this.notify();
1190
+ this.scheduleRender();
1191
+ }
1192
+ /** Toggle read-only (view) mode. */
1193
+ toggleReadOnly() {
1194
+ this.setReadOnly(!this._readOnly);
1195
+ }
923
1196
  /** Whether the background grid is enabled for the scene. */
924
1197
  get gridEnabled() {
925
1198
  return this._scene.viewport.gridEnabled;
@@ -958,6 +1231,8 @@ export class Editor {
958
1231
  * object — Link is readonly). No-op when no edge is selected.
959
1232
  */
960
1233
  updateSelectedLink(updater) {
1234
+ if (this.readOnly)
1235
+ return;
961
1236
  const id = this.selectedLink;
962
1237
  if (id === null)
963
1238
  return;
@@ -1007,9 +1282,11 @@ export class Editor {
1007
1282
  this.gestures.maybeRevertModeAfterCreate();
1008
1283
  }
1009
1284
  setMode(mode) {
1285
+ // A tool switch cancels any armed colour-picker pipette.
1286
+ this.pendingEyedropperPick = null;
1010
1287
  // Switching tools commits any in-flight text edit (standard: leaving the
1011
1288
  // editing context ends it, keeping the typed text).
1012
- if (this._editingTextElement !== null)
1289
+ if (this.editingTextElement !== null)
1013
1290
  this.commitTextEdit();
1014
1291
  // Cancel any in-progress drag gesture so the partial state is not recorded.
1015
1292
  if (this.gestureTx) {
@@ -1100,7 +1377,9 @@ export class Editor {
1100
1377
  * `dispose()` stops it.
1101
1378
  */
1102
1379
  animation = new AnimationController({
1103
- hasVisibleAnimatedElement: () => animScene.hasVisibleAnimatedElement(this),
1380
+ // Laser trails also need a per-frame repaint to animate their fade — OR
1381
+ // them into the tick predicate so the same rAF loop drives both.
1382
+ hasVisibleAnimatedElement: () => animScene.hasVisibleAnimatedElement(this) || this.hasActiveLaser(),
1104
1383
  autoStopHeavyGifs: () => {
1105
1384
  animScene.autoStopHeavyGifs(this);
1106
1385
  },
@@ -1230,6 +1509,8 @@ export class Editor {
1230
1509
  };
1231
1510
  }
1232
1511
  deleteSelected() {
1512
+ if (this.readOnly)
1513
+ return;
1233
1514
  const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
1234
1515
  if (!result)
1235
1516
  return;
@@ -1248,15 +1529,14 @@ export class Editor {
1248
1529
  * cleared by `commitTextEdit` / `cancelTextEdit`. The host overlay
1249
1530
  * (`<TextEditorOverlay>` in `@react-ui`) subscribes via `editor`
1250
1531
  * and renders a `<textarea>` positioned over the shape.
1532
+ * State lives in `TextEditController`; this is a delegate.
1251
1533
  */
1252
- _editingTextElement = null;
1253
1534
  get editingTextElement() {
1254
- return this._editingTextElement;
1535
+ return this.textEdit.editingElement;
1255
1536
  }
1256
1537
  /** Link whose caption is being edited inline (double-click), or null. */
1257
- _editingLinkCaption = null;
1258
1538
  get editingLinkCaption() {
1259
- return this._editingLinkCaption;
1539
+ return this.interaction.editingLinkCaption;
1260
1540
  }
1261
1541
  /**
1262
1542
  * Frame whose NAME (header label) is being edited inline (double-click
@@ -1267,46 +1547,19 @@ export class Editor {
1267
1547
  get editingFrameName() {
1268
1548
  return this._editingFrameName;
1269
1549
  }
1270
- /**
1271
- * When the `draw-text` tool just placed a shape and opened its
1272
- * editor, this holds that shape's id until the first commit. A
1273
- * pending creation isn't in history yet: committing non-empty text
1274
- * records a single add patch (whole shape = one undo); committing
1275
- * empty / cancelling removes it with no history entry at all.
1276
- */
1277
- _pendingTextCreate = null;
1278
- /**
1279
- * Snapshot of the shape at edit start. Used to revert on cancel and
1280
- * as the `before` of the single commit patch. `null` for a pending
1281
- * creation (the shape didn't exist yet).
1282
- */
1283
- _textEditOrigin = null;
1284
- /**
1285
- * Live selection inside the edited text, mirrored from the hidden
1286
- * `<textarea>` (`start`/`end` are source offsets, `dir` is the
1287
- * anchored end). The caret is `dir === "backward" ? start : end`.
1288
- */
1289
- _textSel = null;
1290
- /** Anchor offset for a canvas drag-select inside the edited text. */
1291
- _textDragAnchor = null;
1292
- caretBlink = new CaretBlinkController(() => {
1293
- this.notify();
1294
- });
1295
1550
  get editingTextSelection() {
1296
- return this._textSel;
1551
+ return this.textEdit.selection;
1297
1552
  }
1298
1553
  /** Caret offset = the moving end of the selection. */
1299
1554
  get editingTextCaret() {
1300
- if (!this._textSel)
1301
- return null;
1302
- return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
1555
+ return this.textEdit.caret;
1303
1556
  }
1304
1557
  get caretBlinkOn() {
1305
- return this.caretBlink.on;
1558
+ return this.textEdit.caretBlinkOn;
1306
1559
  }
1307
1560
  /** `true` while a canvas drag-select inside the edited text is active. */
1308
1561
  get isTextDragging() {
1309
- return this._textDragAnchor !== null;
1562
+ return this.textEdit.isDragging;
1310
1563
  }
1311
1564
  /**
1312
1565
  * Begin editing a text shape's body. No-op when the shape doesn't
@@ -1317,9 +1570,9 @@ export class Editor {
1317
1570
  beginLinkCaptionEdit(id) {
1318
1571
  if (!getLink(this._scene, id))
1319
1572
  return;
1320
- if (this._editingTextElement !== null)
1573
+ if (this.editingTextElement !== null)
1321
1574
  this.commitTextEdit();
1322
- this._editingLinkCaption = id;
1575
+ this.interaction.editingLinkCaption = id;
1323
1576
  this.notify();
1324
1577
  }
1325
1578
  /**
@@ -1328,8 +1581,8 @@ export class Editor {
1328
1581
  * styling. One undo step. Clears caption-edit mode.
1329
1582
  */
1330
1583
  commitLinkCaptionEdit(text) {
1331
- const id = this._editingLinkCaption;
1332
- this._editingLinkCaption = null;
1584
+ const id = this.interaction.editingLinkCaption;
1585
+ this.interaction.editingLinkCaption = null;
1333
1586
  if (id === null) {
1334
1587
  this.notify();
1335
1588
  return;
@@ -1356,9 +1609,9 @@ export class Editor {
1356
1609
  }
1357
1610
  /** Cancel link caption editing without changing the label. */
1358
1611
  cancelLinkCaptionEdit() {
1359
- if (this._editingLinkCaption === null)
1612
+ if (this.interaction.editingLinkCaption === null)
1360
1613
  return;
1361
- this._editingLinkCaption = null;
1614
+ this.interaction.editingLinkCaption = null;
1362
1615
  this.notify();
1363
1616
  }
1364
1617
  /** World-space anchor point for a link's caption (midpoint of its path). */
@@ -1387,19 +1640,7 @@ export class Editor {
1387
1640
  return req(path[path.length - 1]);
1388
1641
  }
1389
1642
  beginTextEdit(id) {
1390
- if (!canBeginTextEdit(this._scene, id, (lid) => this.isLayerLocked(lid)))
1391
- return;
1392
- // Commit any in-flight edit on a different shape first.
1393
- if (this._editingTextElement !== null && this._editingTextElement !== id)
1394
- this.commitTextEdit();
1395
- this._editingTextElement = id;
1396
- this._textEditOrigin =
1397
- this._pendingTextCreate === id ? null : (getElement(this._scene, id) ?? null);
1398
- const shape = getElement(this._scene, id);
1399
- const len = shape?.text.length ?? 0;
1400
- this._textSel = { start: len, end: len, dir: "forward" };
1401
- this.caretBlink.start();
1402
- this.notify();
1643
+ this.textEdit.begin(id);
1403
1644
  }
1404
1645
  // --- Frame name inline editing (double-click the header) ---
1405
1646
  /**
@@ -1412,7 +1653,7 @@ export class Editor {
1412
1653
  return;
1413
1654
  if (this.isLayerLocked(shape.layerId))
1414
1655
  return;
1415
- if (this._editingTextElement !== null)
1656
+ if (this.editingTextElement !== null)
1416
1657
  this.commitTextEdit();
1417
1658
  this._editingFrameName = id;
1418
1659
  this.notify();
@@ -1457,22 +1698,11 @@ export class Editor {
1457
1698
  * once on commit. No-op when not editing.
1458
1699
  */
1459
1700
  setEditingText(value, selStart, selEnd, dir = "forward") {
1460
- const id = this._editingTextElement;
1461
- if (!id)
1462
- return;
1463
- const r = updateElement(this._scene, id, (s) => ({ ...s, text: value }));
1464
- this._scene = r.scene;
1465
- this._textSel = { start: selStart, end: selEnd, dir };
1466
- this.caretBlink.wake();
1467
- this.notify();
1701
+ this.textEdit.setText(value, selStart, selEnd, dir);
1468
1702
  }
1469
1703
  /** Selection-only update (arrows / shift-select / click) — no text change. */
1470
1704
  setEditingSelection(selStart, selEnd, dir = "forward") {
1471
- if (!this._editingTextElement)
1472
- return;
1473
- this._textSel = { start: selStart, end: selEnd, dir };
1474
- this.caretBlink.wake();
1475
- this.notify();
1705
+ this.textEdit.setSelection(selStart, selEnd, dir);
1476
1706
  }
1477
1707
  /**
1478
1708
  * Map a world-space point to a caret offset in the edited text. Used
@@ -1480,26 +1710,7 @@ export class Editor {
1480
1710
  * not editing or the shape is gone.
1481
1711
  */
1482
1712
  caretIndexAtWorldPoint(worldPoint) {
1483
- const id = this._editingTextElement;
1484
- if (!id)
1485
- return null;
1486
- const shape = getElement(this._scene, id);
1487
- if (shape?.type !== "text")
1488
- return null;
1489
- const layout = this.editingTextLayout(shape);
1490
- if (!layout)
1491
- return null;
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
- };
1501
- const align = shape.style.textAlign ?? "left";
1502
- return pointToCaretIndex(layout, local, this.measureFor(shape), align);
1713
+ return this.textEdit.caretIndexAtWorldPoint(worldPoint);
1503
1714
  }
1504
1715
  /**
1505
1716
  * `true` when a point is inside the currently-edited text shape's
@@ -1507,65 +1718,19 @@ export class Editor {
1507
1718
  * repositioning the caret (inside) and committing (outside).
1508
1719
  */
1509
1720
  editedElementContainsPoint(worldPoint) {
1510
- const id = this._editingTextElement;
1511
- if (!id)
1512
- return false;
1513
- const shape = getElement(this._scene, id);
1514
- if (!shape)
1515
- return false;
1516
- const b = getElementWorldBounds(shape);
1517
- return (worldPoint.x >= b.x &&
1518
- worldPoint.x <= b.x + b.width &&
1519
- worldPoint.y >= b.y &&
1520
- worldPoint.y <= b.y + b.height);
1721
+ return this.textEdit.editedElementContainsPoint(worldPoint);
1521
1722
  }
1522
1723
  /** Place a collapsed caret at the clicked point and start a drag-select. */
1523
1724
  setTextCaretFromPoint(worldPoint) {
1524
- const idx = this.caretIndexAtWorldPoint(worldPoint);
1525
- if (idx === null)
1526
- return;
1527
- this._textDragAnchor = idx;
1528
- this.setEditingSelection(idx, idx, "forward");
1725
+ this.textEdit.setCaretFromPoint(worldPoint);
1529
1726
  }
1530
1727
  /** Extend the selection from the drag anchor to the current point. */
1531
1728
  extendTextSelectionToPoint(worldPoint) {
1532
- if (this._textDragAnchor === null)
1533
- return;
1534
- const idx = this.caretIndexAtWorldPoint(worldPoint);
1535
- if (idx === null)
1536
- return;
1537
- const anchor = this._textDragAnchor;
1538
- if (idx >= anchor)
1539
- this.setEditingSelection(anchor, idx, "forward");
1540
- else
1541
- this.setEditingSelection(idx, anchor, "backward");
1729
+ this.textEdit.extendSelectionToPoint(worldPoint);
1542
1730
  }
1543
1731
  /** End a canvas drag-select (clears the drag anchor). */
1544
1732
  endTextDragSelect() {
1545
- this._textDragAnchor = null;
1546
- }
1547
- /** Build the editable layout for a text shape using the main target's metrics. */
1548
- editingTextLayout(shape) {
1549
- return layoutText(shape.text, this.measureFor(shape), {
1550
- fontSize: shape.fontSize,
1551
- ...(shape.maxWidth !== undefined ? { maxWidth: shape.maxWidth } : {}),
1552
- });
1553
- }
1554
- /**
1555
- * A measure callback bound to a shape's font, using the main target's
1556
- * `measureText` — the SAME source the renderer draws with (WebGL2
1557
- * reports MSDF advances) and the bounder measures with. Caret /
1558
- * selection geometry therefore lines up exactly with the glyphs.
1559
- */
1560
- measureFor(shape) {
1561
- const target = this.mainTarget;
1562
- // Match the rendered weight/style so caret / selection geometry lines
1563
- // up with bold / italic glyphs (which have different advances).
1564
- target.setFont(shape.fontFamily, shape.fontSize, {
1565
- ...(shape.style.fontWeight === "bold" ? { weight: "bold" } : {}),
1566
- ...(shape.style.fontStyle === "italic" ? { style: "italic" } : {}),
1567
- });
1568
- return (s) => target.measureText(s).width;
1733
+ this.textEdit.endDragSelect();
1569
1734
  }
1570
1735
  /**
1571
1736
  * World-space caret + selection geometry for the overlay pass.
@@ -1573,116 +1738,13 @@ export class Editor {
1573
1738
  * off so the overlay can simply skip drawing it.
1574
1739
  */
1575
1740
  editingTextOverlay() {
1576
- const id = this._editingTextElement;
1577
- if (!id || !this._textSel)
1578
- return null;
1579
- const shape = getElement(this._scene, id);
1580
- if (shape?.type !== "text")
1581
- return null;
1582
- const layout = this.editingTextLayout(shape);
1583
- if (!layout)
1584
- return null;
1585
- const align = shape.style.textAlign ?? "left";
1586
- const measure = this.measureFor(shape);
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;
1594
- const local = textSelectionRects(layout, this._textSel.start, this._textSel.end, measure, align);
1595
- const selectionRects = local.map((r) => ({
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),
1600
- }));
1601
- let caret = null;
1602
- if (this.caretBlink.on) {
1603
- const cIdx = this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
1604
- const g = caretGeometry(layout, cIdx, measure, shape.fontSize, align);
1605
- caret = { x: px + g.x * sx, y: py + g.y * sy, height: g.height * Math.abs(sy) };
1606
- }
1607
- return { caret, caretColor: shape.style.fill ?? "#1a1a1a", selectionRects };
1741
+ return this.textEdit.overlay();
1608
1742
  }
1609
1743
  commitTextEdit(next) {
1610
- const id = this._editingTextElement;
1611
- if (!id)
1612
- return;
1613
- const pending = this._pendingTextCreate === id;
1614
- const origin = this._textEditOrigin;
1615
- // Optional explicit text (keyboard / test callers); the live path
1616
- // passes nothing because the scene already holds the typed text.
1617
- if (next !== undefined) {
1618
- this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: next })).scene;
1619
- }
1620
- this._editingTextElement = null;
1621
- this._pendingTextCreate = null;
1622
- this._textEditOrigin = null;
1623
- this._textSel = null;
1624
- this.caretBlink.stop();
1625
- const finalElement = getElement(this._scene, id);
1626
- const text = finalElement?.text ?? "";
1627
- // Empty (whitespace-only) text removes the shape. Pending = silent
1628
- // (never recorded); existing = recorded so undo restores the origin.
1629
- if (text.trim() === "") {
1630
- if (finalElement) {
1631
- this._scene = removeElement(this._scene, id).scene;
1632
- if (!pending && origin) {
1633
- this._history.push({ kind: "element", id, before: origin, after: null });
1634
- }
1635
- if (this._selection.has(id))
1636
- this._selection = Selection.EMPTY;
1637
- }
1638
- this.notify();
1639
- return;
1640
- }
1641
- if (pending) {
1642
- // Record the whole creation as one add patch.
1643
- if (finalElement)
1644
- this._history.push({ kind: "element", id, before: null, after: finalElement });
1645
- }
1646
- else if (origin && finalElement) {
1647
- // Existing edit: record ONLY the text delta. Other fields (font
1648
- // size etc.) changed via the panel push their own history during
1649
- // the edit, so the commit's `before` keeps the final non-text
1650
- // state and rewinds just the text.
1651
- const originText = origin.text;
1652
- if (originText !== finalElement.text) {
1653
- const before = { ...finalElement, text: originText };
1654
- this._history.push({ kind: "element", id, before, after: finalElement });
1655
- }
1656
- }
1657
- this.notify();
1744
+ this.textEdit.commit(next);
1658
1745
  }
1659
1746
  cancelTextEdit() {
1660
- const id = this._editingTextElement;
1661
- if (id === null)
1662
- return;
1663
- const pending = this._pendingTextCreate === id;
1664
- const origin = this._textEditOrigin;
1665
- this._editingTextElement = null;
1666
- this._pendingTextCreate = null;
1667
- this._textEditOrigin = null;
1668
- this._textSel = null;
1669
- this.caretBlink.stop();
1670
- // Revert live edits with no history entry. Pending creations are
1671
- // removed entirely; existing shapes have only their TEXT restored
1672
- // (panel-driven field changes during the edit keep their own
1673
- // committed history and must survive the cancel).
1674
- if (pending) {
1675
- if (getElement(this._scene, id)) {
1676
- this._scene = removeElement(this._scene, id).scene;
1677
- if (this._selection.has(id))
1678
- this._selection = Selection.EMPTY;
1679
- }
1680
- }
1681
- else if (origin) {
1682
- const originText = origin.text;
1683
- this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: originText })).scene;
1684
- }
1685
- this.notify();
1747
+ this.textEdit.cancel();
1686
1748
  }
1687
1749
  /**
1688
1750
  * Translate every selected shape by the given world-space delta.
@@ -1691,6 +1753,8 @@ export class Editor {
1691
1753
  * and `{ x: 10, y: 0 }` for shift-arrow.
1692
1754
  */
1693
1755
  moveSelectionBy(delta) {
1756
+ if (this.readOnly)
1757
+ return;
1694
1758
  if (this._selection.size === 0 && this._selectedLinks.size === 0)
1695
1759
  return;
1696
1760
  // Locked / layer-locked elements don't move (they're still selectable).
@@ -1755,11 +1819,11 @@ export class Editor {
1755
1819
  const id = newElementIdAtCursor(++this.nextId);
1756
1820
  const shape = buildTextElementAt(this._scene, worldPoint, this._activeLayerId, id);
1757
1821
  // No history push here — the placeholder is "pending" until the
1758
- // first commit (see `_pendingTextCreate`). This way an abandoned
1822
+ // first commit (see `TextEditController.markPendingCreate`). This way an abandoned
1759
1823
  // text never pollutes the undo stack.
1760
1824
  const r = addElement(this._scene, shape);
1761
1825
  this._scene = r.scene;
1762
- this._pendingTextCreate = id;
1826
+ this.textEdit.markPendingCreate(id);
1763
1827
  this._selection = Selection.single(id);
1764
1828
  this.maybeRevertModeAfterCreate();
1765
1829
  this.notify();
@@ -1767,18 +1831,32 @@ export class Editor {
1767
1831
  this.beginTextEdit(id);
1768
1832
  return id;
1769
1833
  }
1834
+ /** Current brush paint settings (line colour, fill, opacity, width). */
1835
+ _brushSettings = DEFAULT_BRUSH_SETTINGS;
1836
+ get brushSettings() {
1837
+ return this._brushSettings;
1838
+ }
1839
+ /**
1840
+ * Update one or more brush paint settings (e.g. from the drawing panel). New
1841
+ * strokes pick them up on commit; the width also drives the pressure curve and
1842
+ * the eraser radius. Merges over the current settings.
1843
+ */
1844
+ setBrushSettings(patch) {
1845
+ this._brushSettings = { ...this._brushSettings, ...patch };
1846
+ this.notify();
1847
+ }
1770
1848
  beginBrushStroke(world, pressure = 0.5) {
1771
- this.brushStroke = beginBrushStrokePure(world, pressure);
1849
+ this.brushStroke = beginBrushStrokePure(world, pressure, this._brushSettings.width);
1772
1850
  this.notify();
1773
1851
  }
1774
1852
  extendBrushStroke(world, pressure = 0.5) {
1775
1853
  if (!this.brushStroke)
1776
1854
  return;
1777
- extendBrushStrokePure(this.brushStroke, world, pressure);
1855
+ extendBrushStrokePure(this.brushStroke, world, pressure, this._brushSettings.width);
1778
1856
  this.notify();
1779
1857
  }
1780
1858
  commitBrushStroke() {
1781
- const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId));
1859
+ const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId), brushStyleFromSettings(this._brushSettings));
1782
1860
  if (!result) {
1783
1861
  this.brushStroke = null;
1784
1862
  this.notify();
@@ -1800,6 +1878,223 @@ export class Editor {
1800
1878
  get pendingBrushStroke() {
1801
1879
  return this.brushStroke;
1802
1880
  }
1881
+ // --- Eraser tool ---
1882
+ /**
1883
+ * Start an eraser stroke at `world`, seeding it with the shape under it so a
1884
+ * plain click erases. With `restore` (Alt held at press) it seeds nothing —
1885
+ * the gesture is in un-mark mode, and there's nothing marked yet to rescue.
1886
+ */
1887
+ beginEraseStroke(world, restore = false, strokeErase = false) {
1888
+ const stroke = beginEraseStrokePure(world, strokeErase);
1889
+ if (!restore) {
1890
+ const hit = this.acceleratedElementAt(world);
1891
+ // In stroke-erase mode brushes are cut by the path, not object-deleted —
1892
+ // don't seed a brush into `pending`.
1893
+ if (hit && !(strokeErase && hit.type === "brush"))
1894
+ stroke.pending.add(hit.id);
1895
+ }
1896
+ this.eraseStroke = stroke;
1897
+ // The initial seed / cut changes the marked set → the first frame must fully
1898
+ // repaint so the dim / cut preview shows.
1899
+ this.eraseDirty = true;
1900
+ // Stroke mode: mark brush points under the press point (degenerate segment)
1901
+ // so a click still cuts.
1902
+ if (strokeErase)
1903
+ this.markStrokeEraseSegment(stroke, world, world);
1904
+ // Start a fading eraser trail (a fresh array so the render-overlay memo
1905
+ // rebuilds this frame — same reasoning as `beginLaserStroke`).
1906
+ this.interaction.eraserTrail = [beginLaserStrokePure(world, nowMs())];
1907
+ this.maybeAnimate();
1908
+ this.notify();
1909
+ }
1910
+ /**
1911
+ * Incrementally mark the brush points erased by the eraser segment `a → b`
1912
+ * (world). Iterates the current brushes and grows `stroke.erased` in place —
1913
+ * O(points) per move (each already-erased point is skipped), so a long drag
1914
+ * no longer costs O(points × path length) per frame. Radius is the on-screen
1915
+ * eraser ring converted to world units.
1916
+ */
1917
+ markStrokeEraseSegment(stroke, a, b) {
1918
+ const zoom = this._scene.viewport.zoom || 1;
1919
+ const radius = this._brushSettings.width / zoom;
1920
+ let changed = false;
1921
+ for (const el of this._scene.elements.values()) {
1922
+ if (!isBrush(el))
1923
+ continue;
1924
+ const existing = stroke.erased.get(el.id) ?? [];
1925
+ const merged = markErasedIntervals(el, existing, a, b, radius);
1926
+ // Grew the covered span (or first coverage of a single-point brush).
1927
+ if (merged.length > existing.length ||
1928
+ coveredLength(merged) > coveredLength(existing) + 1e-6) {
1929
+ stroke.erased.set(el.id, merged);
1930
+ changed = true;
1931
+ }
1932
+ }
1933
+ return changed;
1934
+ }
1935
+ /**
1936
+ * Extend the eraser stroke to `world`, sweeping shapes along the segment.
1937
+ * `restore` (Alt held) un-marks swept shapes instead of marking them.
1938
+ */
1939
+ extendEraseStroke(world, restore = false) {
1940
+ const stroke = this.eraseStroke;
1941
+ if (!stroke)
1942
+ return;
1943
+ const changed = sampleErasePure(stroke.last, world, (p) => this.acceleratedElementAt(p), stroke.pending, restore, stroke.strokeMode);
1944
+ // Incrementally cut brush points along the new segment (stroke mode only).
1945
+ const cut = stroke.strokeMode ? this.markStrokeEraseSegment(stroke, stroke.last, world) : false;
1946
+ // Only a frame that actually changed the marked / cut set needs the forced
1947
+ // full repaint (see `eraseDirty`); a move over already-covered area doesn't.
1948
+ if (changed || cut)
1949
+ this.eraseDirty = true;
1950
+ stroke.last = world;
1951
+ // Grow the fading trail alongside the sweep. Reassign the array reference
1952
+ // (like the laser) so the overlay memo repaints the trail on this move. If
1953
+ // the trail had faded to empty (a pause with the button held), start a fresh
1954
+ // one — otherwise resuming the drag would leave `eraserTrail` empty.
1955
+ const trail = this.interaction.eraserTrail;
1956
+ const active = trail[trail.length - 1];
1957
+ if (active) {
1958
+ extendLaserStrokePure(active, world, nowMs());
1959
+ this.interaction.eraserTrail = trail.slice();
1960
+ }
1961
+ else {
1962
+ this.interaction.eraserTrail = [beginLaserStrokePure(world, nowMs())];
1963
+ }
1964
+ this.maybeAnimate();
1965
+ // Always repaint: the cursor ring follows the pointer every move regardless
1966
+ // of whether anything was marked / cut. Cheap now — a frame that changes
1967
+ // nothing skips the full main pass (see `eraseDirty`) and only redraws the
1968
+ // overlay cursor / trail.
1969
+ this.notify();
1970
+ }
1971
+ /**
1972
+ * Commit the eraser stroke — delete every swept shape in ONE undo step (with
1973
+ * their attached links). No-op delete when nothing was swept. Returns the
1974
+ * count removed.
1975
+ */
1976
+ commitEraseStroke() {
1977
+ const stroke = this.eraseStroke;
1978
+ if (!stroke)
1979
+ return 0;
1980
+ this.eraseStroke = null;
1981
+ // Object-erase part: delete every swept (non-brush, in stroke mode) shape.
1982
+ const objectResult = computeEraseCommit(this._scene, stroke.pending);
1983
+ let scene = objectResult ? objectResult.scene : this._scene;
1984
+ const patches = objectResult ? [...objectResult.patches] : [];
1985
+ // Stroke-erase part (Shift): cut every brush with erased points (accumulated
1986
+ // incrementally during the drag) into fragments.
1987
+ const removedBrushIds = [];
1988
+ if (stroke.strokeMode) {
1989
+ const strokeResult = computeEraseFromMasks(scene, stroke.erased, () => newBrushId(++this.nextId));
1990
+ if (strokeResult) {
1991
+ scene = strokeResult.scene;
1992
+ patches.push(...strokeResult.patches);
1993
+ removedBrushIds.push(...strokeResult.removedIds);
1994
+ }
1995
+ }
1996
+ if (patches.length === 0) {
1997
+ this.notify();
1998
+ return 0;
1999
+ }
2000
+ // Fold the object-deletes and brush cuts into ONE undo step.
2001
+ const tx = this._history.transaction();
2002
+ this._scene = scene;
2003
+ for (const patch of patches)
2004
+ tx.add(patch);
2005
+ tx.commit();
2006
+ // Drop any erased ids from the live selection so no stale handle lingers.
2007
+ let sel = this._selection;
2008
+ for (const id of stroke.pending)
2009
+ sel = Selection.remove(sel, id);
2010
+ for (const id of removedBrushIds)
2011
+ sel = Selection.remove(sel, id);
2012
+ this._selection = sel;
2013
+ this.notify();
2014
+ return stroke.pending.size + removedBrushIds.length;
2015
+ }
2016
+ /** Abort the eraser stroke without deleting anything. */
2017
+ cancelEraseStroke() {
2018
+ if (!this.eraseStroke)
2019
+ return;
2020
+ this.eraseStroke = null;
2021
+ this.notify();
2022
+ }
2023
+ // --- Laser pointer ---
2024
+ /** True only while the pointer is down in laser mode (a trail is being laid). */
2025
+ get laserDrawing() {
2026
+ return this.interaction.laserDrawing;
2027
+ }
2028
+ /** Start a laser trail at `world` (ephemeral — never enters the scene). */
2029
+ beginLaserStroke(world) {
2030
+ // Reassign the array (not just `.push`) so its identity changes: the
2031
+ // render-overlay memo keys on the `laserStrokes` reference, so an in-place
2032
+ // mutation would leave the signature unchanged and the memo would reuse a
2033
+ // stale options bag that omits the trail — the trail then wouldn't paint
2034
+ // until a later prune reallocated the array (~TTL later). A fresh reference
2035
+ // forces the memo to rebuild and the trail to render on this very frame.
2036
+ this.interaction.laserStrokes = [
2037
+ ...this.interaction.laserStrokes,
2038
+ beginLaserStrokePure(world, nowMs()),
2039
+ ];
2040
+ this.interaction.laserDrawing = true;
2041
+ this.maybeAnimate();
2042
+ this.notify();
2043
+ }
2044
+ /** Append a point to the active laser trail (no-op unless drawing). */
2045
+ extendLaserStroke(world) {
2046
+ if (!this.interaction.laserDrawing)
2047
+ return;
2048
+ const strokes = this.interaction.laserStrokes;
2049
+ const active = strokes[strokes.length - 1];
2050
+ if (!active)
2051
+ return;
2052
+ extendLaserStrokePure(active, world, nowMs());
2053
+ // Fresh array reference (same reasoning as `beginLaserStroke`) so the
2054
+ // overlay memo rebuilds and repaints the growing trail on THIS move,
2055
+ // instead of waiting for the animation loop to happen to reallocate it.
2056
+ this.interaction.laserStrokes = strokes.slice();
2057
+ this.maybeAnimate();
2058
+ this.notify();
2059
+ }
2060
+ /** End the active laser trail — it keeps fading via the animation tick. */
2061
+ endLaserStroke() {
2062
+ this.interaction.laserDrawing = false;
2063
+ // The stroke is already ephemeral and the tick prunes it; re-arm in case
2064
+ // the tick wasn't running.
2065
+ this.maybeAnimate();
2066
+ }
2067
+ /**
2068
+ * True while any laser trail OR eraser trail still has visible points (drives
2069
+ * the fade tick — so the trail keeps melting after the pointer stops).
2070
+ */
2071
+ hasActiveLaser() {
2072
+ return this.interaction.laserStrokes.length > 0 || this.interaction.eraserTrail.length > 0;
2073
+ }
2074
+ /** Live eraser drag trail (ephemeral, fading). Empty when none active. */
2075
+ get eraserTrail() {
2076
+ return this.interaction.eraserTrail;
2077
+ }
2078
+ /**
2079
+ * Drop expired laser/eraser trail points (called once per frame before paint).
2080
+ * Self-terminating: once both arrays empty the animation tick stops.
2081
+ */
2082
+ pruneLaser() {
2083
+ const strokes = this.interaction.laserStrokes;
2084
+ if (strokes.length > 0) {
2085
+ const r = pruneLaserStrokes(strokes, nowMs());
2086
+ if (r.changed)
2087
+ this.interaction.laserStrokes = r.strokes;
2088
+ }
2089
+ const trail = this.interaction.eraserTrail;
2090
+ if (trail.length > 0) {
2091
+ // Prune at the eraser's own (shorter) TTL so points don't linger in the
2092
+ // array long after they've faded to invisible.
2093
+ const r = pruneLaserStrokes(trail, nowMs(), ERASER_TRAIL_TTL_MS);
2094
+ if (r.changed)
2095
+ this.interaction.eraserTrail = r.strokes;
2096
+ }
2097
+ }
1803
2098
  arrangeAsGrid(opts = {}) {
1804
2099
  const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
1805
2100
  const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
@@ -1821,6 +2116,8 @@ export class Editor {
1821
2116
  this.announce(`Stacked ${result.count} shapes ${result.direction}`);
1822
2117
  }
1823
2118
  groupSelected() {
2119
+ if (this.readOnly)
2120
+ return { kind: "noop" };
1824
2121
  const result = computeGroupSelected(this._scene, this._selection, newGroupElementId(++this.nextId));
1825
2122
  if (!result)
1826
2123
  return { kind: "noop" };
@@ -1834,6 +2131,8 @@ export class Editor {
1834
2131
  return { kind: "grouped", groupId: result.groupId };
1835
2132
  }
1836
2133
  ungroup() {
2134
+ if (this.readOnly)
2135
+ return;
1837
2136
  const result = computeUngroup(this._scene, this._selection);
1838
2137
  if (!result)
1839
2138
  return;
@@ -1870,22 +2169,24 @@ export class Editor {
1870
2169
  // scene to exactly where it was (cancelling the history tx alone wouldn't).
1871
2170
  this.cancelGesture();
1872
2171
  this.actor.send({ type: "POINTER_CANCEL" });
1873
- this.drawingPreview = null;
1874
- this.edgePreview = null;
1875
- this.lassoPreview = null;
2172
+ this.interaction.resetPreviews();
1876
2173
  // Abort a host-managed link-from-anchor gesture too — it lives outside
1877
2174
  // the machine, so POINTER_CANCEL above doesn't touch it. Without this a
1878
2175
  // gesture left mid-flight would keep its preview after Escape.
1879
- this.linkDragFromAnchor = null;
1880
- this.hoveredLinkTarget = null;
1881
- this.hoverCursorWorld = null;
1882
- this._editingLinkCaption = null;
1883
- this.pendingLinkDropMenu = null;
1884
- this.linkWaypointDrag = null;
1885
- this.linkSegmentDrag = null;
1886
- // Endpoint-rebind drag: gestureTx.cancel above already reverted the live
1887
- // re-point; just drop the handle-preview state so the dot stops tracking.
1888
- this.linkEndpointDrag = null;
2176
+ this.interaction.linkDragFromAnchor = null;
2177
+ this.interaction.editingLinkCaption = null;
2178
+ this.interaction.pendingLinkDropMenu = null;
2179
+ // Abort an in-progress eraser stroke (nothing deleted) and stop laying a
2180
+ // laser trail — both live outside the machine. Existing laser and eraser
2181
+ // trails keep fading via the tick (not hard-cleared here).
2182
+ this.interaction.eraseStroke = null;
2183
+ this.interaction.laserDrawing = false;
2184
+ // Drop any pending flowchart-create preview (Esc / global cancel abandons it).
2185
+ this.flowchartSession = null;
2186
+ // Waypoint / segment / endpoint-rebind drags: gestureTx.cancel above
2187
+ // already reverted the live re-point; just drop the handle-drag state so
2188
+ // the dots stop tracking.
2189
+ this.linkHandles.reset();
1889
2190
  // Esc exits group-isolation if active. The selection that was
1890
2191
  // active inside the group is dropped (Esc reads as a full
1891
2192
  // "back out" — selecting the group is a separate gesture).
@@ -1902,6 +2203,8 @@ export class Editor {
1902
2203
  * Links between selected shapes are NOT cloned. Single undo step.
1903
2204
  */
1904
2205
  duplicateSelected() {
2206
+ if (this.readOnly)
2207
+ return;
1905
2208
  const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
1906
2209
  if (!result)
1907
2210
  return;
@@ -1924,6 +2227,8 @@ export class Editor {
1924
2227
  * originals. One undo step.
1925
2228
  */
1926
2229
  duplicateSelectedInPlace(anchorId = null) {
2230
+ if (this.readOnly)
2231
+ return null;
1927
2232
  if (this._selection.size === 0)
1928
2233
  return null;
1929
2234
  // Expand: selection + group descendants (parentId) + frame members (frameId).
@@ -1970,6 +2275,23 @@ export class Editor {
1970
2275
  this._selectedLinks = LinkSelection.EMPTY;
1971
2276
  this.notify();
1972
2277
  }
2278
+ /**
2279
+ * Programmatically select a single link by id (or clear the link
2280
+ * selection with `null`), clearing the element selection so the link
2281
+ * becomes the sole selection. Used by host navigation (search / jump-to)
2282
+ * to frame an edge with {@link zoomToSelection}. No-op when nothing
2283
+ * would change.
2284
+ */
2285
+ selectLink(id) {
2286
+ const nextLinks = id === null ? LinkSelection.EMPTY : LinkSelection.single(id);
2287
+ const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
2288
+ const elementsChanged = this._selection.size > 0;
2289
+ if (!linksChanged && !elementsChanged)
2290
+ return;
2291
+ this._selection = Selection.EMPTY;
2292
+ this._selectedLinks = nextLinks;
2293
+ this.notify();
2294
+ }
1973
2295
  selectAll() {
1974
2296
  const next = computeSelectAll(this._scene, this._selection);
1975
2297
  const nextLinks = computeSelectAllLinks(this._scene);
@@ -2000,6 +2322,8 @@ export class Editor {
2000
2322
  this.announce(`Copied ${out.length} shapes`);
2001
2323
  }
2002
2324
  cutSelected() {
2325
+ if (this.readOnly)
2326
+ return;
2003
2327
  this.copySelected();
2004
2328
  this.deleteSelected();
2005
2329
  }
@@ -2061,6 +2385,8 @@ export class Editor {
2061
2385
  return this.styleClipboard !== null;
2062
2386
  }
2063
2387
  updateStyle(ids, partial) {
2388
+ if (this.readOnly)
2389
+ return;
2064
2390
  const result = computeUpdateStyle(this._scene, ids, partial);
2065
2391
  if (!result)
2066
2392
  return;
@@ -2068,12 +2394,457 @@ export class Editor {
2068
2394
  this._history.push(result.patch);
2069
2395
  this.notify();
2070
2396
  }
2397
+ /**
2398
+ * Apply a partial text style (bold / italic / colour / decoration) to the
2399
+ * character range `[from, to)` of a single text element, producing styled
2400
+ * runs (rich text). One undo step. No-op when the id isn't a text shape or
2401
+ * the range is empty. Read-only editors ignore it. Use this — rather than
2402
+ * `updateStyle` — to style only PART of a text block (e.g. the current
2403
+ * inline-edit selection).
2404
+ */
2405
+ applyTextStyleToRange(id, from, to, partial) {
2406
+ if (this.readOnly)
2407
+ return;
2408
+ const result = computeApplyTextRunStyle(this._scene, id, from, to, partial);
2409
+ if (!result)
2410
+ return;
2411
+ this._scene = result.scene;
2412
+ this._history.push(result.patch);
2413
+ this.notify();
2414
+ }
2415
+ // --- F8: Eyedropper ---------------------------------------------------
2416
+ /**
2417
+ * The fill (or stroke, per `role`) colour of the top-most shape under the
2418
+ * world point, or `null` on empty canvas. Pure read — no mutation.
2419
+ */
2420
+ pickColorAt(worldPoint, role = "fill") {
2421
+ return pickColorAt(this._scene, worldPoint, role);
2422
+ }
2423
+ /** One-shot callback armed by {@link beginEyedropperPick}; consumes the next canvas click. */
2424
+ pendingEyedropperPick = null;
2425
+ /** `true` while a colour-picker pipette is armed and waiting for a canvas click. */
2426
+ get isEyedropperArmed() {
2427
+ return this.pendingEyedropperPick !== null;
2428
+ }
2429
+ /**
2430
+ * Arm the eyedropper for a one-shot canvas pick that routes the sampled colour
2431
+ * to `onPick` (e.g. a colour-picker swatch) instead of the selection fill. Does
2432
+ * NOT change the tool mode — the next canvas press is intercepted by
2433
+ * {@link applyEyedropperAt}. Cancelled by a mode switch or an empty-canvas click.
2434
+ */
2435
+ beginEyedropperPick(onPick) {
2436
+ this.pendingEyedropperPick = onPick;
2437
+ this.refreshCursor();
2438
+ this.notify();
2439
+ }
2440
+ /**
2441
+ * Sample the colour under `worldPoint`. When a pipette pick is armed (see
2442
+ * {@link beginEyedropperPick}), route the colour to that callback and disarm.
2443
+ * Otherwise (legacy tool path) apply it as the current selection's fill and
2444
+ * revert to `select` mode. Returns the sampled colour, or `null` on empty
2445
+ * canvas. Read-only editors sample but don't mutate.
2446
+ */
2447
+ applyEyedropperAt(worldPoint) {
2448
+ const color = pickColorAt(this._scene, worldPoint, "fill");
2449
+ const pick = this.pendingEyedropperPick;
2450
+ if (pick !== null) {
2451
+ this.pendingEyedropperPick = null;
2452
+ if (color !== null)
2453
+ pick(color);
2454
+ this.refreshCursor();
2455
+ this.notify();
2456
+ return color;
2457
+ }
2458
+ if (color === null)
2459
+ return null;
2460
+ if (!this.readOnly && this._selection.size > 0) {
2461
+ this.updateStyle(this._selection, { fill: color });
2462
+ }
2463
+ if (this.mode === "eyedropper" && !this.toolLocked)
2464
+ this.setMode("select");
2465
+ return color;
2466
+ }
2467
+ // --- F9: Convert element type ----------------------------------------
2468
+ /**
2469
+ * Convert every convertible selected shape (rectangle / ellipse / diamond)
2470
+ * to `target`, preserving position, size and style. One undo step; no-op
2471
+ * when nothing applies. See {@link ConvertTarget}.
2472
+ */
2473
+ convertSelection(target) {
2474
+ if (this.readOnly)
2475
+ return;
2476
+ const result = computeConvertType(this._scene, this._selection, target);
2477
+ if (!result)
2478
+ return;
2479
+ this._scene = result.scene;
2480
+ this._history.push(result.patch);
2481
+ this.notify();
2482
+ }
2483
+ // --- F11: Spawn connected node ---------------------------------------
2484
+ /**
2485
+ * Flowchart auto-generate: clone the single selected node offset in
2486
+ * `direction` and connect the two with a fresh link. Selects the new node.
2487
+ * No-op unless exactly one element is selected. One undo step.
2488
+ */
2489
+ spawnConnectedNode(direction) {
2490
+ if (this.readOnly)
2491
+ return;
2492
+ const ids = [...this._selection];
2493
+ const sourceId = ids.length === 1 ? ids[0] : undefined;
2494
+ if (sourceId === undefined)
2495
+ return;
2496
+ const result = computeSpawnConnectedNode(this._scene, sourceId, direction, newElementId(++this.nextId), newLinkId(++this.nextId));
2497
+ if (!result)
2498
+ return;
2499
+ this._scene = result.scene;
2500
+ this._history.push({ kind: "batch", patches: [...result.patches] });
2501
+ this.setSelection([result.newElementId]);
2502
+ this.notify();
2503
+ }
2504
+ // --- Flowchart CREATE session (Cmd/Ctrl+Arrow, Excalidraw-style) ------
2505
+ /**
2506
+ * Pending flowchart-create session, or `null` when idle. Holds the ORIGINAL
2507
+ * source id + direction, the current sibling `count`, and the pending
2508
+ * `elements` + `links` (a PREVIEW — not yet in the scene / history). Grown by
2509
+ * {@link growFlowchart}, committed by {@link commitFlowchart}, discarded by
2510
+ * {@link cancelFlowchart}.
2511
+ */
2512
+ flowchartSession = null;
2513
+ /**
2514
+ * Grow the flowchart-create preview one step in `direction`. Starts a session
2515
+ * (count = 1) when idle or when the direction changes; otherwise bumps the
2516
+ * sibling count up to {@link FLOWCHART_MAX_SIBLINGS}. Recomputes the pending
2517
+ * nodes/links from the ORIGINAL source each call. PREVIEW ONLY — never
2518
+ * touches the scene or history until {@link commitFlowchart}. No-op in
2519
+ * read-only mode or unless exactly one element is selected.
2520
+ */
2521
+ growFlowchart(direction) {
2522
+ if (this._readOnly)
2523
+ return;
2524
+ if (this._selection.size !== 1)
2525
+ return;
2526
+ const ids = [...this._selection];
2527
+ const sourceId = ids[0];
2528
+ if (sourceId === undefined || getElement(this._scene, sourceId) === undefined)
2529
+ return;
2530
+ const session = this.flowchartSession;
2531
+ const count = session?.direction === direction && session.sourceId === sourceId
2532
+ ? Math.min(session.count + 1, FLOWCHART_MAX_SIBLINGS)
2533
+ : 1;
2534
+ const { elements, links } = computeSpawnConnectedNodes(this._scene, sourceId, direction, count, () => newElementId(++this.nextId), () => newLinkId(++this.nextId));
2535
+ this.flowchartSession = { sourceId, direction, count, elements, links };
2536
+ this.notify();
2537
+ }
2538
+ /**
2539
+ * Commit the pending flowchart-create preview: add every pending node + link
2540
+ * to the scene as ONE undo step, select the first new node, clear the
2541
+ * session. Returns the first new node's id, or `null` when no session is
2542
+ * active.
2543
+ */
2544
+ commitFlowchart() {
2545
+ const session = this.flowchartSession;
2546
+ if (session === null)
2547
+ return null;
2548
+ let s = this._scene;
2549
+ const patches = [];
2550
+ for (const el of session.elements) {
2551
+ const r = addElement(s, el);
2552
+ s = r.scene;
2553
+ patches.push(r.patch);
2554
+ }
2555
+ for (const link of session.links) {
2556
+ const r = addLink(s, link);
2557
+ s = r.scene;
2558
+ patches.push(r.patch);
2559
+ }
2560
+ this.flowchartSession = null;
2561
+ const first = session.elements[0]?.id ?? null;
2562
+ if (patches.length === 0) {
2563
+ this.notify();
2564
+ return first;
2565
+ }
2566
+ this._scene = s;
2567
+ this._history.push({ kind: "batch", patches });
2568
+ if (first !== null)
2569
+ this.setSelection([first]);
2570
+ this.notify();
2571
+ return first;
2572
+ }
2573
+ /** Discard the pending flowchart-create preview without committing. */
2574
+ cancelFlowchart() {
2575
+ if (this.flowchartSession === null)
2576
+ return;
2577
+ this.flowchartSession = null;
2578
+ this.notify();
2579
+ }
2580
+ /**
2581
+ * The pending flowchart-create preview (nodes + links), or `null` when no
2582
+ * session is active. Read by the render snapshot to paint the preview on the
2583
+ * overlay. Reference-stable between renders (only changes on grow / commit /
2584
+ * cancel) so the overlay memo doesn't thrash.
2585
+ */
2586
+ get flowchartPreview() {
2587
+ const session = this.flowchartSession;
2588
+ if (session === null)
2589
+ return null;
2590
+ return { elements: session.elements, links: session.links };
2591
+ }
2592
+ /**
2593
+ * Move the selection to an adjacent node. With exactly one element selected,
2594
+ * prefers a graph neighbour (linked node) best aligned with `direction`;
2595
+ * falls back to the spatial {@link selectClosest} when no neighbour lies that
2596
+ * way. No-op unless exactly one element is selected.
2597
+ */
2598
+ navigateFlowchart(direction) {
2599
+ if (this._selection.size !== 1)
2600
+ return;
2601
+ const ids = [...this._selection];
2602
+ const sourceId = ids[0];
2603
+ if (sourceId === undefined)
2604
+ return;
2605
+ // Graph neighbours: every element linked to the source by any edge.
2606
+ const neighbours = new Set();
2607
+ for (const link of this._scene.links.values()) {
2608
+ const a = endpointElementId(link.from);
2609
+ const b = endpointElementId(link.to);
2610
+ if (a === sourceId && b !== undefined && b !== sourceId)
2611
+ neighbours.add(b);
2612
+ else if (b === sourceId && a !== undefined && a !== sourceId)
2613
+ neighbours.add(a);
2614
+ }
2615
+ if (neighbours.size > 0) {
2616
+ const ref = this.combinedSelectionBounds();
2617
+ const refCenter = ref
2618
+ ? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
2619
+ : { x: 0, y: 0 };
2620
+ const best = findClosestInDirection(this._scene, this._selection, direction, refCenter, (el) => this.isElementInteractable(el) && neighbours.has(el.id));
2621
+ if (best !== null) {
2622
+ this.setSelection([best]);
2623
+ return;
2624
+ }
2625
+ }
2626
+ // No graph neighbour that way — fall back to spatial nearest.
2627
+ this.selectClosest(direction);
2628
+ }
2629
+ // --- F10: Image crop --------------------------------------------------
2630
+ /**
2631
+ * Live image-crop session, or `null` when not cropping. Excalidraw-style:
2632
+ * the crop frame IS the element's visible box, and the user drags edge /
2633
+ * corner handles inward (hides pixels) or the image body (pans the source).
2634
+ *
2635
+ * - `id` — the image being cropped.
2636
+ * - `crop` — pending normalised source rect.
2637
+ * - `position` / `width` / `height` — the pending element box (world position
2638
+ * + local size); a handle drag moves them, a body pan leaves them fixed.
2639
+ * - `drag` — the active gesture, or `null` when only hovering.
2640
+ * - `dragStartWorld` — pointer world position at drag start (body pan basis).
2641
+ *
2642
+ * Seeded on {@link beginImageCrop}; committed by {@link commitImageCrop} (one
2643
+ * undo step), abandoned by {@link cancelImageCrop}.
2644
+ */
2645
+ cropSession = null;
2646
+ /** The image-crop session (read-only accessor for UI / overlay). */
2647
+ get imageCropSession() {
2648
+ const s = this.cropSession;
2649
+ if (s === null)
2650
+ return null;
2651
+ return { id: s.id, crop: s.crop, position: s.position, width: s.width, height: s.height };
2652
+ }
2653
+ /**
2654
+ * Enter crop mode for the image `id`, seeding the pending crop / box from its
2655
+ * current state (or the full image). No-op for non-image shapes or in
2656
+ * read-only. Typically triggered by a double-click on an image.
2657
+ */
2658
+ beginImageCrop(id) {
2659
+ if (this.readOnly)
2660
+ return;
2661
+ const el = getElement(this._scene, id);
2662
+ if (el === undefined || !isImage(el))
2663
+ return;
2664
+ this.cancelInteraction();
2665
+ this._selection = Selection.single(id);
2666
+ this.cropSession = {
2667
+ id,
2668
+ crop: el.crop ?? FULL_CROP,
2669
+ position: el.position,
2670
+ width: el.width,
2671
+ height: el.height,
2672
+ drag: null,
2673
+ dragStartWorld: null,
2674
+ };
2675
+ this.setMode("crop");
2676
+ this.refreshCursor();
2677
+ this.notify();
2678
+ }
2679
+ /**
2680
+ * Hit-test `worldPoint` against the pending crop chrome: a crop handle when
2681
+ * within {@link CROP_HANDLE_HIT_RADIUS} (screen px, zoom-compensated) of one,
2682
+ * `"body"` when inside the window, else `null`. Returns `null` when not
2683
+ * cropping.
2684
+ */
2685
+ cropHandleAtWorld(worldPoint) {
2686
+ const session = this.cropSession;
2687
+ if (session === null)
2688
+ return null;
2689
+ const el = getElement(this._scene, session.id);
2690
+ if (el === undefined)
2691
+ return null;
2692
+ const pending = this.pendingCropElement(el);
2693
+ const points = cropHandleWorldPoints(pending);
2694
+ const zoom = this._scene.viewport.zoom || 1;
2695
+ const radius = CROP_HANDLE_HIT_RADIUS / zoom;
2696
+ for (const handle of CROP_HANDLES) {
2697
+ const p = points[handle];
2698
+ if (Math.hypot(worldPoint.x - p.x, worldPoint.y - p.y) <= radius)
2699
+ return handle;
2700
+ }
2701
+ const local = worldToLocal(pending, worldPoint);
2702
+ if (local.x >= 0 && local.x <= session.width && local.y >= 0 && local.y <= session.height) {
2703
+ return "body";
2704
+ }
2705
+ return null;
2706
+ }
2707
+ /** Begin dragging crop handle `handle` from `worldPoint`. */
2708
+ beginImageCropHandle(handle, worldPoint) {
2709
+ if (this.cropSession === null)
2710
+ return;
2711
+ this.cropSession = {
2712
+ ...this.cropSession,
2713
+ drag: { kind: "handle", handle },
2714
+ dragStartWorld: worldPoint,
2715
+ };
2716
+ }
2717
+ /** Begin panning the image body under the fixed window from `worldPoint`. */
2718
+ beginImageCropBody(worldPoint) {
2719
+ if (this.cropSession === null)
2720
+ return;
2721
+ this.cropSession = { ...this.cropSession, drag: { kind: "body" }, dragStartWorld: worldPoint };
2722
+ }
2723
+ /**
2724
+ * Update the active crop drag to `worldPoint` — resize the window (handle) or
2725
+ * pan the source (body). Geometry is recomputed from the ORIGINAL element so
2726
+ * it stays stable across many moves. No-op when no drag is active.
2727
+ */
2728
+ updateImageCropDrag(worldPoint) {
2729
+ const session = this.cropSession;
2730
+ if (session?.drag == null || session.dragStartWorld === null)
2731
+ return;
2732
+ const el = getElement(this._scene, session.id);
2733
+ if (el === undefined)
2734
+ return;
2735
+ const baseCrop = el.crop ?? FULL_CROP;
2736
+ if (session.drag.kind === "handle") {
2737
+ const r = computeCropHandleDrag(el, baseCrop, session.drag.handle, worldPoint);
2738
+ this.cropSession = {
2739
+ ...session,
2740
+ crop: r.crop,
2741
+ position: r.position,
2742
+ width: r.width,
2743
+ height: r.height,
2744
+ };
2745
+ }
2746
+ else {
2747
+ const r = computeCropBodyPan(el, baseCrop, session.dragStartWorld, worldPoint);
2748
+ this.cropSession = { ...session, crop: r.crop };
2749
+ }
2750
+ this.notify();
2751
+ }
2752
+ /** Finish the current crop drag (keeps the pending crop / box). */
2753
+ endImageCropDrag() {
2754
+ if (this.cropSession === null)
2755
+ return;
2756
+ this.cropSession = { ...this.cropSession, drag: null, dragStartWorld: null };
2757
+ }
2758
+ /** Apply the pending crop + box and leave crop mode. One undo step. */
2759
+ commitImageCrop() {
2760
+ const session = this.cropSession;
2761
+ if (session === null)
2762
+ return;
2763
+ const result = computeCommitImageCrop(this._scene, session.id, {
2764
+ crop: session.crop,
2765
+ position: session.position,
2766
+ width: session.width,
2767
+ height: session.height,
2768
+ });
2769
+ this.cropSession = null;
2770
+ if (result) {
2771
+ this._scene = result.scene;
2772
+ this._history.push(result.patch);
2773
+ }
2774
+ this.setMode("select");
2775
+ this.refreshCursor();
2776
+ this.notify();
2777
+ }
2778
+ /** Abandon the crop session without changing the image. */
2779
+ cancelImageCrop() {
2780
+ if (this.cropSession === null)
2781
+ return;
2782
+ this.cropSession = null;
2783
+ this.setMode("select");
2784
+ this.refreshCursor();
2785
+ this.notify();
2786
+ }
2787
+ /**
2788
+ * Synthetic element carrying the PENDING crop box (position / size) over the
2789
+ * original element's rotation / scale — the frame the user currently sees.
2790
+ * Used to project the crop frame and handles.
2791
+ */
2792
+ pendingCropElement(el) {
2793
+ const session = this.cropSession;
2794
+ if (session === null)
2795
+ return el;
2796
+ return {
2797
+ ...el,
2798
+ position: session.position,
2799
+ width: session.width,
2800
+ height: session.height,
2801
+ };
2802
+ }
2803
+ /**
2804
+ * World-space corners (clockwise) of the pending crop frame, or `null` when
2805
+ * not cropping. The frame is the pending element box mapped through its
2806
+ * local→world transform (so rotation / scale are honoured).
2807
+ */
2808
+ cropFrameCorners() {
2809
+ const session = this.cropSession;
2810
+ if (session === null)
2811
+ return null;
2812
+ const el = getElement(this._scene, session.id);
2813
+ if (el === undefined)
2814
+ return null;
2815
+ const pending = this.pendingCropElement(el);
2816
+ const b = getElementLocalBounds(pending);
2817
+ return [
2818
+ { x: b.x, y: b.y },
2819
+ { x: b.x + b.width, y: b.y },
2820
+ { x: b.x + b.width, y: b.y + b.height },
2821
+ { x: b.x, y: b.y + b.height },
2822
+ ].map((p) => localToWorld(pending, p));
2823
+ }
2824
+ /**
2825
+ * Ghost-image overlay descriptor for the crop session: the ORIGINAL element
2826
+ * (its transform + live bitmap handle) and the virtual full-image LOCAL rect
2827
+ * the whole bitmap occupies. `null` when not cropping. The overlay paints the
2828
+ * full bitmap faintly over this rect so hidden parts stay visible.
2829
+ */
2830
+ cropGhost() {
2831
+ const session = this.cropSession;
2832
+ if (session === null)
2833
+ return null;
2834
+ const el = getElement(this._scene, session.id);
2835
+ if (el === undefined)
2836
+ return null;
2837
+ const baseCrop = el.crop ?? FULL_CROP;
2838
+ return { element: el, fullRect: cropFullImageLocalRect(el, baseCrop) };
2839
+ }
2071
2840
  /**
2072
2841
  * Update non-style text properties (`fontSize`, `fontFamily`,
2073
2842
  * `maxWidth`) on every selected text shape. Non-text shapes are
2074
2843
  * skipped. Single undo step. Used by the text contextual panel.
2075
2844
  */
2076
2845
  updateTextProps(ids, partial) {
2846
+ if (this.readOnly)
2847
+ return;
2077
2848
  const result = computeUpdateTextProps(this._scene, ids, partial);
2078
2849
  if (!result)
2079
2850
  return;
@@ -2087,6 +2858,8 @@ export class Editor {
2087
2858
  * undoable step; no-op when no text is selected.
2088
2859
  */
2089
2860
  adjustSelectionFontSize(direction) {
2861
+ if (this.readOnly)
2862
+ return;
2090
2863
  const result = computeAdjustFontSize(this._scene, this._selection, direction);
2091
2864
  if (!result)
2092
2865
  return;
@@ -2103,6 +2876,8 @@ export class Editor {
2103
2876
  * hover link-popup.
2104
2877
  */
2105
2878
  setLink(ids, href) {
2879
+ if (this.readOnly)
2880
+ return;
2106
2881
  const normalized = href === null ? null : normalizeHref(href);
2107
2882
  const result = computeSetLink(this._scene, ids, normalized);
2108
2883
  if (!result)
@@ -2141,6 +2916,8 @@ export class Editor {
2141
2916
  return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
2142
2917
  }
2143
2918
  bringToFront(id) {
2919
+ if (this.readOnly)
2920
+ return;
2144
2921
  const result = computeBringToFront(this._scene, id, this._selection);
2145
2922
  if (!result)
2146
2923
  return;
@@ -2149,6 +2926,8 @@ export class Editor {
2149
2926
  this.notify();
2150
2927
  }
2151
2928
  sendToBack(id) {
2929
+ if (this.readOnly)
2930
+ return;
2152
2931
  const result = computeSendToBack(this._scene, id, this._selection);
2153
2932
  if (!result)
2154
2933
  return;
@@ -2158,6 +2937,8 @@ export class Editor {
2158
2937
  }
2159
2938
  /** Move the target shape one step toward the top of its layer. */
2160
2939
  bringForward(id) {
2940
+ if (this.readOnly)
2941
+ return;
2161
2942
  const result = computeBringForward(this._scene, id, this._selection);
2162
2943
  if (!result)
2163
2944
  return;
@@ -2167,6 +2948,8 @@ export class Editor {
2167
2948
  }
2168
2949
  /** Move the target shape one step toward the bottom of its layer. */
2169
2950
  sendBackward(id) {
2951
+ if (this.readOnly)
2952
+ return;
2170
2953
  const result = computeSendBackward(this._scene, id, this._selection);
2171
2954
  if (!result)
2172
2955
  return;
@@ -2220,6 +3003,8 @@ export class Editor {
2220
3003
  }
2221
3004
  /** Apply a batch of arrange patches as a single undoable step. */
2222
3005
  commitArrange(patches) {
3006
+ if (this.readOnly)
3007
+ return;
2223
3008
  if (patches.length === 0)
2224
3009
  return;
2225
3010
  const tx = this._history.transaction();
@@ -2254,6 +3039,8 @@ export class Editor {
2254
3039
  * surprising and the operation is rarely chained with other edits.
2255
3040
  */
2256
3041
  clear() {
3042
+ if (this.readOnly)
3043
+ return;
2257
3044
  if (this._scene.elements.size === 0 && this._scene.links.size === 0)
2258
3045
  return;
2259
3046
  this._scene = {
@@ -2326,6 +3113,8 @@ export class Editor {
2326
3113
  this.notify();
2327
3114
  }
2328
3115
  moveSelectionToLayer(targetLayer) {
3116
+ if (this.readOnly)
3117
+ return;
2329
3118
  const result = computeMoveSelectionToLayer(this._scene, this._selection, targetLayer);
2330
3119
  if (!result)
2331
3120
  return;
@@ -2388,6 +3177,22 @@ export class Editor {
2388
3177
  this._scene = next;
2389
3178
  this.notify();
2390
3179
  }
3180
+ /**
3181
+ * Center the camera on the current selection for a reveal / jump-to (search
3182
+ * navigation). Unlike {@link zoomToSelection}, it does NOT fill the screen —
3183
+ * a small match keeps its size and is merely centered; the zoom only drops to
3184
+ * fit an oversized match. No-op when the selection is empty.
3185
+ */
3186
+ revealSelection(padding = 80) {
3187
+ const bounds = this.combinedSelectionBounds();
3188
+ if (!bounds)
3189
+ return;
3190
+ const next = computeRevealBounds(this._scene, bounds, padding);
3191
+ if (!next)
3192
+ return;
3193
+ this._scene = next;
3194
+ this.notify();
3195
+ }
2391
3196
  /**
2392
3197
  * Select the nearest interactable top-level element in `direction` from the
2393
3198
  * current selection's centre (or the viewport centre when nothing is
@@ -2444,7 +3249,7 @@ export class Editor {
2444
3249
  * of the modifier to this. Idempotent; never touches history.
2445
3250
  */
2446
3251
  setSnapSuppressed(suppressed) {
2447
- this.snapSuppressed = suppressed;
3252
+ this.interaction.snapSuppressed = suppressed;
2448
3253
  }
2449
3254
  /**
2450
3255
  * Host hook: mirror the Alt / Shift modifier state so an in-flight resize or
@@ -2453,8 +3258,8 @@ export class Editor {
2453
3258
  * keydown/keyup of the modifiers to this. Idempotent; never touches history.
2454
3259
  */
2455
3260
  setTransformModifiers(mods) {
2456
- this.transformAltKey = mods.alt;
2457
- this.transformShiftKey = mods.shift;
3261
+ this.interaction.transformAltKey = mods.alt;
3262
+ this.interaction.transformShiftKey = mods.shift;
2458
3263
  }
2459
3264
  /**
2460
3265
  * True when a gesture should snap. Snapping is coupled to grid display:
@@ -2464,7 +3269,7 @@ export class Editor {
2464
3269
  */
2465
3270
  snapActive() {
2466
3271
  const viewport = this._scene.viewport;
2467
- return !this.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport);
3272
+ return (!this.interaction.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport));
2468
3273
  }
2469
3274
  /** World-unit spacing the current gesture snaps to. */
2470
3275
  snapSpacing() {
@@ -2500,6 +3305,10 @@ export class Editor {
2500
3305
  // Restore transient animationData (GIF bytes) from Scene.files
2501
3306
  // before the tick so the animation adapter can decode frames.
2502
3307
  animScene.rehydrateAnimatedImages(this);
3308
+ // Rebuild live handles for static images from Scene.files — their
3309
+ // serialised handle is gone and `src` is a dead `blob:` URL after a
3310
+ // reload. Async; repaints itself when the decode lands.
3311
+ void animScene.rehydrateStaticImages(this);
2503
3312
  this.notify();
2504
3313
  // Loaded scene may carry animated shapes (e.g. GIF re-imported
2505
3314
  // from saved JSON). Re-arm the tick — `metadata.animated` survives
@@ -2718,6 +3527,8 @@ export class Editor {
2718
3527
  * can't be moved or resized.
2719
3528
  */
2720
3529
  toggleLockSelection() {
3530
+ if (this.readOnly)
3531
+ return;
2721
3532
  if (this._selection.size === 0)
2722
3533
  return;
2723
3534
  const ids = [...this._selection];
@@ -2789,9 +3600,41 @@ export class Editor {
2789
3600
  computeHiddenElements() {
2790
3601
  return computeHiddenElementsPure(this._scene);
2791
3602
  }
3603
+ /**
3604
+ * Live stroke-erase preview: while a Shift-held eraser gesture drags, the
3605
+ * fragments each touched brush WOULD become, plus the set of touched
3606
+ * originals to hide in the main pass. `null` outside a stroke-erase gesture
3607
+ * or when the path touches no brush. Recomputed each frame from the
3608
+ * path-so-far — never mutates the scene or history.
3609
+ */
3610
+ computeStrokeErasePreview() {
3611
+ const stroke = this.eraseStroke;
3612
+ if (!stroke || !stroke.strokeMode || stroke.erased.size === 0)
3613
+ return null;
3614
+ const preview = computeStrokeErasePreviewFromMasks(this._scene, stroke.erased);
3615
+ if (!preview)
3616
+ return null;
3617
+ return { elements: preview.fragments, hidden: preview.hidden };
3618
+ }
2792
3619
  computeDimElements(enteredGroupId) {
2793
3620
  return computeDimElementsHelper(this._scene, this._selection, enteredGroupId);
2794
3621
  }
3622
+ /**
3623
+ * Dim set fed to the renderer: group-isolation dim UNION the eraser's
3624
+ * pending-delete set (shapes swept by the current eraser stroke are shown
3625
+ * dimmed so the user sees what release will delete). `undefined` when neither
3626
+ * is active, keeping the fast tile-cache render path.
3627
+ */
3628
+ computeDimSet() {
3629
+ const group = this._enteredGroup ? this.computeDimElements(this._enteredGroup) : undefined;
3630
+ const erase = this.interaction.eraseStroke?.pending;
3631
+ if (!erase || erase.size === 0)
3632
+ return group;
3633
+ const merged = new Set(group);
3634
+ for (const id of erase)
3635
+ merged.add(id);
3636
+ return merged;
3637
+ }
2795
3638
  /**
2796
3639
  * Enter a group — subsequent hits inside this group return children
2797
3640
  * directly instead of the group root. `null` exits group-edit mode.
@@ -2915,11 +3758,11 @@ export class Editor {
2915
3758
  */
2916
3759
  routeIsolationClick(clickEffect, worldPoint) {
2917
3760
  const now = performance.now();
2918
- const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
2919
- this.lastClickWorldPoint !== null &&
2920
- vec2.distance(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
2921
- this.lastClickAt = now;
2922
- this.lastClickWorldPoint = worldPoint;
3761
+ const isDouble = now - this.interaction.lastClickAt < DOUBLE_CLICK_MS &&
3762
+ this.interaction.lastClickWorldPoint !== null &&
3763
+ vec2.distance(this.interaction.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
3764
+ this.interaction.lastClickAt = now;
3765
+ this.interaction.lastClickWorldPoint = worldPoint;
2923
3766
  // Double-click the frame HEADER (label strip above the body) → rename.
2924
3767
  // Checked before the clickEffect gate because the header sits outside
2925
3768
  // the frame's hit-test bounds, so the click produces SELECT_CLEAR (or
@@ -3002,6 +3845,12 @@ export class Editor {
3002
3845
  return pickDrillTargetHelper(this._scene, raw, top, this._enteredGroup);
3003
3846
  }
3004
3847
  applyEmit(emit) {
3848
+ // Read-only gate: in view mode every scene-mutating emit is dropped
3849
+ // (create / move / resize / rotate / annotation / edge edits + their
3850
+ // live previews). Selection + lasso emits fall through so a viewer can
3851
+ // still click / marquee-select and pan / zoom the document.
3852
+ if (this._readOnly && READ_ONLY_BLOCKED_EMITS.has(emit.type))
3853
+ return;
3005
3854
  switch (emit.type) {
3006
3855
  case "SELECT_REPLACE":
3007
3856
  // Plain element click replaces the whole selection (elements + links).
@@ -3045,19 +3894,19 @@ export class Editor {
3045
3894
  case "LASSO_PROGRESS":
3046
3895
  // Capture the pre-lasso selection on the first progress emit
3047
3896
  // of a gesture; subsequent emits use it as the additive base.
3048
- this.lassoBaseSelection ??= this._selection;
3049
- this.lassoBaseLinks ??= this._selectedLinks;
3897
+ this.interaction.lassoBaseSelection ??= this._selection;
3898
+ this.interaction.lassoBaseLinks ??= this._selectedLinks;
3050
3899
  this.lassoPreview = emit.bounds;
3051
3900
  this.applyLassoLiveSelection(emit.bounds, emit.mode);
3052
3901
  this.notify();
3053
3902
  return;
3054
3903
  case "LASSO_CLEAR":
3055
3904
  if (this.lassoPreview !== null ||
3056
- this.lassoBaseSelection !== null ||
3057
- this.lassoBaseLinks !== null) {
3905
+ this.interaction.lassoBaseSelection !== null ||
3906
+ this.interaction.lassoBaseLinks !== null) {
3058
3907
  this.lassoPreview = null;
3059
- this.lassoBaseSelection = null;
3060
- this.lassoBaseLinks = null;
3908
+ this.interaction.lassoBaseSelection = null;
3909
+ this.interaction.lassoBaseLinks = null;
3061
3910
  this.notify();
3062
3911
  }
3063
3912
  return;
@@ -3065,8 +3914,8 @@ export class Editor {
3065
3914
  // Final commit — uses the same logic as the live preview so
3066
3915
  // the visible selection matches what lands. Reset the base
3067
3916
  // snapshot so the next gesture re-captures it.
3068
- this.lassoBaseSelection = null;
3069
- this.lassoBaseLinks = null;
3917
+ this.interaction.lassoBaseSelection = null;
3918
+ this.interaction.lassoBaseLinks = null;
3070
3919
  this.applySelectByBounds(emit.bounds, emit.mode);
3071
3920
  return;
3072
3921
  case "MOVE_SHAPE":
@@ -3173,7 +4022,7 @@ export class Editor {
3173
4022
  if (el && !this.isElementManipulable(el))
3174
4023
  return;
3175
4024
  // Shift constrains the drag to one axis before snapping.
3176
- const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
4025
+ const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
3177
4026
  const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
3178
4027
  const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
3179
4028
  if (!patch)
@@ -3186,7 +4035,7 @@ export class Editor {
3186
4035
  if (!this.groupMoveOrigin)
3187
4036
  return;
3188
4037
  // Shift constrains the drag to one axis before snapping.
3189
- const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
4038
+ const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
3190
4039
  const d = this.snapActive()
3191
4040
  ? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
3192
4041
  : moved;
@@ -3261,7 +4110,14 @@ export class Editor {
3261
4110
  this.drawingPreview !== null ||
3262
4111
  this.edgePreview !== null ||
3263
4112
  this.brushStroke !== null ||
3264
- this.lassoPreview !== null) {
4113
+ this.lassoPreview !== null ||
4114
+ // Eraser sweep: marking / un-marking / cutting changes what's shown
4115
+ // WITHOUT mutating the scene, so the scene-diff dirty rect is empty and
4116
+ // the change would never repaint. Force a full repaint — but ONLY on the
4117
+ // frames that actually changed the marked / cut set (`eraseDirty`), so a
4118
+ // slowly-moving or stopped cursor doesn't re-render the whole scene every
4119
+ // frame (which froze big scenes).
4120
+ (this.interaction.eraseStroke !== null && this.eraseDirty)) {
3265
4121
  return null;
3266
4122
  }
3267
4123
  // Anything that affects the global render — viewport (pan / zoom /
@@ -3275,6 +4131,12 @@ export class Editor {
3275
4131
  // repaint when the entered-group identity changes between frames.
3276
4132
  if (this.lastRenderedEnteredGroup !== this._enteredGroup)
3277
4133
  return null;
4134
+ // Eraser just STOPPED (the active-stroke guard above already returned, so
4135
+ // `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
4136
+ // without a scene change — commit changes the scene and is caught by the
4137
+ // diff — so force a full repaint that frame or the dim would linger.
4138
+ if (this.lastRenderedEraseActive)
4139
+ return null;
3278
4140
  // Diff the two scenes for the dirty rect + tile-cache invalidation. The
3279
4141
  // state-coupled guards above stay here; the pure scene diff lives in
3280
4142
  // `computeSceneDirtyRect`.
@@ -3356,7 +4218,7 @@ export class Editor {
3356
4218
  const gesture = this.rotateGestureOrigin;
3357
4219
  if (!gesture)
3358
4220
  return;
3359
- const d = this.transformShiftKey
4221
+ const d = this.interaction.transformShiftKey
3360
4222
  ? Math.round(deltaAngle / ROTATE_SNAP_RADIANS) * ROTATE_SNAP_RADIANS
3361
4223
  : deltaAngle;
3362
4224
  const patches = computeRotatePatches(this._scene, gesture.origin, gesture.pivot, d);
@@ -3375,7 +4237,7 @@ export class Editor {
3375
4237
  const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
3376
4238
  // Aspect-locked when the selection type demands it (images / groups) or
3377
4239
  // the user holds Shift for this gesture.
3378
- this.selectionIsAspectLocked() || this.transformShiftKey, this.transformAltKey);
4240
+ this.selectionIsAspectLocked() || this.interaction.transformShiftKey, this.interaction.transformAltKey);
3379
4241
  this._scene = result.scene;
3380
4242
  for (const patch of result.patches)
3381
4243
  this.recordGesturePatch(patch);
@@ -3389,9 +4251,9 @@ export class Editor {
3389
4251
  // on the first tick so the closed-form never compounds. Grid-snap is
3390
4252
  // skipped — snapping a tilted box to the world grid is ill-defined.
3391
4253
  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);
4254
+ if (this.interaction.resizeOriginElement?.id !== id)
4255
+ this.interaction.resizeOriginElement = shape;
4256
+ const result = computeRotatedElementResize(this._scene, this.interaction.resizeOriginElement, handle, delta, this.interaction.transformShiftKey, this.interaction.transformAltKey);
3395
4257
  if (!result)
3396
4258
  return;
3397
4259
  this._scene = result.scene;
@@ -3405,10 +4267,14 @@ export class Editor {
3405
4267
  // Text: aspect-locked font scaling. Snapshot the pristine shape on
3406
4268
  // the gesture's first tick so the scale base never compounds.
3407
4269
  if (shape !== undefined && isText(shape)) {
3408
- if (this._resizeOriginElement?.id !== id) {
3409
- this._resizeOriginElement = shape;
4270
+ if (this.interaction.resizeOriginElement?.id !== id) {
4271
+ this.interaction.resizeOriginElement = shape;
3410
4272
  }
3411
- const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds, this.transformAltKey);
4273
+ // The snapshot was taken from a text shape above; fall back to the
4274
+ // live shape if a stale non-text snapshot ever leaks through.
4275
+ const origin = this.interaction.resizeOriginElement;
4276
+ const textOrigin = isText(origin) ? origin : shape;
4277
+ const result = computeTextResize(this._scene, textOrigin, handle, d, originalBounds, this.interaction.transformAltKey);
3412
4278
  if (!result)
3413
4279
  return;
3414
4280
  this._scene = result.scene;
@@ -3416,7 +4282,7 @@ export class Editor {
3416
4282
  this.notify();
3417
4283
  return;
3418
4284
  }
3419
- const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.transformShiftKey, this.transformAltKey);
4285
+ const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.interaction.transformShiftKey, this.interaction.transformAltKey);
3420
4286
  if (!result)
3421
4287
  return;
3422
4288
  this._scene = result.scene;
@@ -3476,13 +4342,13 @@ export class Editor {
3476
4342
  // the drop point (standard). The free-ended link stays; picking re-points
3477
4343
  // it, dismissing keeps it. Only the `to` end is user-dragged here.
3478
4344
  if (to.kind === "point") {
3479
- this.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
4345
+ this.interaction.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
3480
4346
  }
3481
4347
  this.notify();
3482
4348
  }
3483
4349
  /** Pending shape-picker after a link was dropped on empty canvas. */
3484
4350
  get linkDropMenu() {
3485
- return this.pendingLinkDropMenu;
4351
+ return this.interaction.pendingLinkDropMenu;
3486
4352
  }
3487
4353
  /**
3488
4354
  * Resolve a pending link-drop shape-picker by creating an element from
@@ -3491,12 +4357,12 @@ export class Editor {
3491
4357
  * new element becomes the selection. No-op when no menu is pending.
3492
4358
  */
3493
4359
  placeShapeAtLinkDrop(factory) {
3494
- const pending = this.pendingLinkDropMenu;
4360
+ const pending = this.interaction.pendingLinkDropMenu;
3495
4361
  if (!pending)
3496
4362
  return;
3497
4363
  const link = getLink(this._scene, pending.linkId);
3498
4364
  if (!link) {
3499
- this.pendingLinkDropMenu = null;
4365
+ this.interaction.pendingLinkDropMenu = null;
3500
4366
  this.notify();
3501
4367
  return;
3502
4368
  }
@@ -3507,16 +4373,16 @@ export class Editor {
3507
4373
  tx.add(r.addPatch);
3508
4374
  tx.add(r.linkPatch);
3509
4375
  tx.commit();
3510
- this.pendingLinkDropMenu = null;
4376
+ this.interaction.pendingLinkDropMenu = null;
3511
4377
  this._selection = Selection.single(newId);
3512
4378
  this._selectedLinks = LinkSelection.EMPTY;
3513
4379
  this.notify();
3514
4380
  }
3515
4381
  /** Dismiss the link-drop shape-picker, leaving the free-ended link. */
3516
4382
  dismissLinkDropMenu() {
3517
- if (!this.pendingLinkDropMenu)
4383
+ if (!this.interaction.pendingLinkDropMenu)
3518
4384
  return;
3519
- this.pendingLinkDropMenu = null;
4385
+ this.interaction.pendingLinkDropMenu = null;
3520
4386
  this.notify();
3521
4387
  }
3522
4388
  /**
@@ -3580,9 +4446,9 @@ export class Editor {
3580
4446
  this.notify();
3581
4447
  }
3582
4448
  applyLassoLiveSelection(bounds, mode) {
3583
- const base = this.lassoBaseSelection ?? Selection.EMPTY;
4449
+ const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
3584
4450
  const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
3585
- const linkBase = this.lassoBaseLinks ?? LinkSelection.EMPTY;
4451
+ const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
3586
4452
  const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
3587
4453
  const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
3588
4454
  if (Selection.equals(next, this._selection) && !linksChanged)
@@ -3601,57 +4467,14 @@ export class Editor {
3601
4467
  * link snaps back to where it was. The handle dot follows via `linkEndpointDrag`.
3602
4468
  */
3603
4469
  applyLinkEndpointMove(linkId, side, toPoint) {
3604
- const edge = getLink(this._scene, linkId);
3605
- if (!edge)
3606
- return;
3607
- // A real drag breaks the handle double-click chain (mirrors waypoint /
3608
- // segment drags) so a quick click after dropping isn't read as a delete.
3609
- this.lastHandleClickAt = 0;
3610
- // Resolve the attach target under the cursor and snap the endpoint to it
3611
- // with the SAME logic the drop uses, so the link attaches LIVE exactly as it
3612
- // will commit — lands on the dot (fixed), floats on the body, or stays a
3613
- // free point over empty space.
3614
- const target = this.linkAttachTargetAt(toPoint);
3615
- const targetId = target?.kind === "element" ? target.id : null;
3616
- const ep = this.snapLinkEndpoint(targetId, toPoint);
3617
- const r = updateLink(this._scene, linkId, (e) => side === "from" ? { ...e, from: ep } : { ...e, to: ep });
3618
- this._scene = r.scene;
3619
- this.recordGesturePatch(r.patch);
3620
- this.linkEndpointDrag = { linkId, side, toPoint };
3621
- // Attach-point highlight — the SAME feedback as drawing a new link
3622
- // (candidate dots + float-element halo), driven by `hoveredLinkTarget`.
3623
- this.updateHoveredLinkTarget(toPoint);
3624
- this.notify();
4470
+ this.linkHandles.applyEndpointMove(linkId, side, toPoint);
3625
4471
  }
3626
4472
  applyLinkEndpointUpdate(emit) {
3627
- // A move opened a gesture transaction (live re-point per tick). The final
3628
- // snapped endpoint goes into the SAME transaction so the net history step is
3629
- // original → final (one undo). A pure click (no move, no tx) that resolves
3630
- // to a no-op change must not leave a junk undo entry.
3631
- const moved = this.gestureTx !== null;
3632
- const result = computeLinkEndpointUpdate(this._scene, emit, (toElement, toPoint) => this.snapLinkEndpoint(toElement, toPoint));
3633
- if (result === null) {
3634
- this.cancelGesture();
3635
- this.linkEndpointDrag = null;
3636
- this.hoveredLinkTarget = null;
3637
- this.notify();
3638
- return;
3639
- }
3640
- if (!moved && isNoop(result.patch)) {
3641
- this.linkEndpointDrag = null;
3642
- this.hoveredLinkTarget = null;
3643
- this.notify();
3644
- return;
3645
- }
3646
- this._scene = result.scene;
3647
- this.recordGesturePatch(result.patch);
3648
- this.commitGesture();
3649
- this.linkEndpointDrag = null;
3650
- this.hoveredLinkTarget = null;
4473
+ this.linkHandles.applyEndpointUpdate(emit);
3651
4474
  }
3652
4475
  /** True while a waypoint of the selected link is being dragged. */
3653
4476
  get isDraggingWaypoint() {
3654
- return this.linkWaypointDrag !== null;
4477
+ return this.linkHandles.isDraggingWaypoint;
3655
4478
  }
3656
4479
  /**
3657
4480
  * Begin a host-managed waypoint drag. `insert` splices a new waypoint at
@@ -3660,34 +4483,11 @@ export class Editor {
3660
4483
  * transaction so the whole drag is one undo step.
3661
4484
  */
3662
4485
  beginWaypointDrag(linkId, index, insert) {
3663
- if (!getLink(this._scene, linkId))
3664
- return;
3665
- this.linkWaypointDrag = { linkId, index, pendingInsert: insert };
4486
+ this.linkHandles.beginWaypointDrag(linkId, index, insert);
3666
4487
  }
3667
4488
  /** Live update of the dragged waypoint to `world`. */
3668
4489
  updateWaypointDrag(world) {
3669
- const drag = this.linkWaypointDrag;
3670
- if (!drag)
3671
- return;
3672
- // A real drag breaks the handle double-click chain (see updateSegmentDrag).
3673
- this.lastHandleClickAt = 0;
3674
- const edge = getLink(this._scene, drag.linkId);
3675
- if (!edge)
3676
- return;
3677
- const wps = [...(edge.waypoints ?? [])];
3678
- if (drag.pendingInsert) {
3679
- wps.splice(drag.index, 0, world);
3680
- drag.pendingInsert = false;
3681
- }
3682
- else {
3683
- if (drag.index < 0 || drag.index >= wps.length)
3684
- return;
3685
- wps[drag.index] = world;
3686
- }
3687
- const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
3688
- this._scene = r.scene;
3689
- this.recordGesturePatch(r.patch);
3690
- this.notify();
4490
+ this.linkHandles.updateWaypointDrag(world);
3691
4491
  }
3692
4492
  /**
3693
4493
  * Finish the waypoint drag. If the dragged waypoint landed within
@@ -3695,38 +4495,11 @@ export class Editor {
3695
4495
  * (drag-onto-the-line to delete). A no-move insert adds nothing.
3696
4496
  */
3697
4497
  endWaypointDrag() {
3698
- const drag = this.linkWaypointDrag;
3699
- this.linkWaypointDrag = null;
3700
- if (!drag)
3701
- return;
3702
- if (drag.pendingInsert) {
3703
- // Never moved → it was a click on a midpoint; nothing inserted.
3704
- this.commitGesture();
3705
- return;
3706
- }
3707
- const edge = getLink(this._scene, drag.linkId);
3708
- if (edge?.waypoints && drag.index >= 0 && drag.index < edge.waypoints.length) {
3709
- const path = getLinkPath(this._scene, edge);
3710
- const wp = req(edge.waypoints[drag.index]);
3711
- // Neighbours in the [from, ...waypoints, to] chain: path[index] and
3712
- // path[index + 2] (path[0] = from, so waypoint i sits at path[i + 1]).
3713
- // Dropping the waypoint back onto the straight segment between its
3714
- // neighbours removes the bend ("drag onto the line to delete").
3715
- const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
3716
- const a = path?.[drag.index];
3717
- const b = path?.[drag.index + 2];
3718
- if (a && b && hitTest.distanceToSegment(wp, a, b) <= collapse) {
3719
- const wps = edge.waypoints.filter((_, i) => i !== drag.index);
3720
- const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
3721
- this._scene = r.scene;
3722
- this.recordGesturePatch(r.patch);
3723
- }
3724
- }
3725
- this.commitGesture();
4498
+ this.linkHandles.endWaypointDrag();
3726
4499
  }
3727
4500
  /** True while an elbow segment is being dragged. */
3728
4501
  get isDraggingSegment() {
3729
- return this.linkSegmentDrag !== null;
4502
+ return this.linkHandles.isDraggingSegment;
3730
4503
  }
3731
4504
  /**
3732
4505
  * Begin a host-managed elbow segment drag. `axis` is the segment's
@@ -3734,9 +4507,7 @@ export class Editor {
3734
4507
  * across re-routes).
3735
4508
  */
3736
4509
  beginSegmentDrag(linkId, axis, at) {
3737
- if (!getLink(this._scene, linkId))
3738
- return;
3739
- this.linkSegmentDrag = { linkId, axis, at };
4510
+ this.linkHandles.beginSegmentDrag(linkId, axis, at);
3740
4511
  }
3741
4512
  /**
3742
4513
  * Move the dragged elbow segment perpendicular to its axis: pin its
@@ -3744,34 +4515,11 @@ export class Editor {
3744
4515
  * rest around the pin (one undo step via the gesture transaction).
3745
4516
  */
3746
4517
  updateSegmentDrag(world) {
3747
- const drag = this.linkSegmentDrag;
3748
- if (!drag)
3749
- return;
3750
- // A real drag breaks the handle double-click chain, so a single click
3751
- // right after pinning can't be misread as a double-click (= delete).
3752
- this.lastHandleClickAt = 0;
3753
- const edge = getLink(this._scene, drag.linkId);
3754
- if (!edge)
3755
- return;
3756
- const pos = drag.axis === "h" ? world.y : world.x;
3757
- const fixed = [...(edge.fixedSegments ?? [])];
3758
- const entry = { axis: drag.axis, pos, at: drag.at };
3759
- const at = fixed.findIndex((f) => f.axis === drag.axis && Math.abs(f.at - drag.at) < 0.5);
3760
- if (at >= 0)
3761
- fixed[at] = entry;
3762
- else
3763
- fixed.push(entry);
3764
- const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, fixedSegments: fixed }));
3765
- this._scene = r.scene;
3766
- this.recordGesturePatch(r.patch);
3767
- this.notify();
4518
+ this.linkHandles.updateSegmentDrag(world);
3768
4519
  }
3769
4520
  /** Finish the elbow segment drag (commit the gesture as one undo step). */
3770
4521
  endSegmentDrag() {
3771
- if (!this.linkSegmentDrag)
3772
- return;
3773
- this.linkSegmentDrag = null;
3774
- this.commitGesture();
4522
+ this.linkHandles.endSegmentDrag();
3775
4523
  }
3776
4524
  /**
3777
4525
  * Double-click detector for link edit handles (waypoint / segment).
@@ -3781,27 +4529,14 @@ export class Editor {
3781
4529
  * `onDown`, so that path never sees them).
3782
4530
  */
3783
4531
  isHandleDoubleClick(world) {
3784
- const now = performance.now();
3785
- const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
3786
- this.lastHandleClickWorld !== null &&
3787
- vec2.distance(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
3788
- this.lastHandleClickAt = now;
3789
- this.lastHandleClickWorld = world;
3790
- return isDouble;
4532
+ return this.linkHandles.isHandleDoubleClick(world);
3791
4533
  }
3792
4534
  /**
3793
4535
  * Delete a free bend point (waypoint) from a straight / bezier link by
3794
4536
  * index — double-click a waypoint handle to remove it. One undo step.
3795
4537
  */
3796
4538
  deleteWaypoint(linkId, index) {
3797
- const edge = getLink(this._scene, linkId);
3798
- if (!edge?.waypoints || index < 0 || index >= edge.waypoints.length)
3799
- return;
3800
- const wps = edge.waypoints.filter((_, i) => i !== index);
3801
- const r = updateLink(this._scene, linkId, (e) => ({ ...e, waypoints: wps }));
3802
- this._scene = r.scene;
3803
- this._history.push(r.patch);
3804
- this.notify();
4539
+ this.linkHandles.deleteWaypoint(linkId, index);
3805
4540
  }
3806
4541
  /**
3807
4542
  * Remove the pinned (fixed) elbow segment that matches the given
@@ -3812,28 +4547,7 @@ export class Editor {
3812
4547
  * step.
3813
4548
  */
3814
4549
  resetSegmentPin(linkId, axis, pos, at) {
3815
- const edge = getLink(this._scene, linkId);
3816
- if (!edge?.fixedSegments || edge.fixedSegments.length === 0)
3817
- return;
3818
- let bestIdx = -1;
3819
- let bestD = Infinity;
3820
- for (let i = 0; i < edge.fixedSegments.length; i++) {
3821
- const f = req(edge.fixedSegments[i]);
3822
- if (f.axis !== axis)
3823
- continue;
3824
- const d = Math.abs(f.pos - pos) + Math.abs(f.at - at) * 0.001;
3825
- if (d < bestD) {
3826
- bestD = d;
3827
- bestIdx = i;
3828
- }
3829
- }
3830
- if (bestIdx < 0)
3831
- return;
3832
- const fixed = edge.fixedSegments.filter((_, i) => i !== bestIdx);
3833
- const r = updateLink(this._scene, linkId, (e) => ({ ...e, fixedSegments: fixed }));
3834
- this._scene = r.scene;
3835
- this._history.push(r.patch);
3836
- this.notify();
4550
+ this.linkHandles.resetSegmentPin(linkId, axis, pos, at);
3837
4551
  }
3838
4552
  /** Whether the selected link has obstacle-avoidance routing enabled. */
3839
4553
  get selectedLinkAvoidsObstacles() {
@@ -3852,6 +4566,8 @@ export class Editor {
3852
4566
  * derived (recomputed by `rerouteElbows`). No-op when no link is selected.
3853
4567
  */
3854
4568
  setSelectedLinkAvoidObstacles(enabled) {
4569
+ if (this.readOnly)
4570
+ return;
3855
4571
  const id = this.selectedLink;
3856
4572
  if (id === null)
3857
4573
  return;
@@ -3866,7 +4582,7 @@ export class Editor {
3866
4582
  this._scene = r.scene;
3867
4583
  this._history.push(r.patch);
3868
4584
  // Force the next reroute to recompute with the new mode.
3869
- this.elbowRouteSig.delete(id);
4585
+ this.elbowRoutes.delete(id);
3870
4586
  this.notify();
3871
4587
  }
3872
4588
  updateHoveredLinkTarget(worldPoint) {
@@ -3889,7 +4605,8 @@ export class Editor {
3889
4605
  const onTarget = result.all.filter((c) => c.metadata?.elementId === shape.id);
3890
4606
  const anchor = onTarget.find((c) => c.kind === "anchor");
3891
4607
  const outline = onTarget.find((c) => c.kind === "outline");
3892
- const ref = anchor?.metadata?.ref;
4608
+ const refMeta = anchor?.metadata?.ref;
4609
+ const ref = isAnchorRef(refMeta) ? refMeta : undefined;
3893
4610
  let activeName = null;
3894
4611
  if (ref?.kind === "named") {
3895
4612
  activeName = ref.name;
@@ -3939,7 +4656,7 @@ export class Editor {
3939
4656
  this.gestures.record(patch);
3940
4657
  }
3941
4658
  commitGesture() {
3942
- this._resizeOriginElement = null;
4659
+ this.interaction.resizeOriginElement = null;
3943
4660
  this.rotateGestureOrigin = null;
3944
4661
  this.gestures.commit();
3945
4662
  this.gestureStartScene = null;
@@ -3984,8 +4701,8 @@ export class Editor {
3984
4701
  return this.gestureTx;
3985
4702
  }
3986
4703
  cancelGesture() {
3987
- this._resizeOriginElement = null;
3988
- this.rotateGestureOrigin = null;
4704
+ this.interaction.resizeOriginElement = null;
4705
+ this.interaction.rotateGestureOrigin = null;
3989
4706
  this.gestures.cancel();
3990
4707
  // Roll the scene back to the pre-gesture snapshot — cancelling the history
3991
4708
  // transaction alone leaves the live drag mutations in `_scene`.
@@ -4112,17 +4829,33 @@ export class Editor {
4112
4829
  return patch;
4113
4830
  }
4114
4831
  /**
4115
- * Per-link signature of the inputs that determine an elbow route
4116
- * (endpoint refs + bound-shape bounds + fixedSegments). When unchanged
4117
- * between frames the A* route is reused see `rerouteElbows`.
4118
- */
4119
- elbowRouteSig = new Map();
4120
- /**
4121
- * Choke-point reroute (standard model): recompute `routedPoints` for
4122
- * every orthogonal link whose inputs changed since the last pass, and
4123
- * bake the result into `_scene`. Runs once per frame before paint —
4124
- * derived state, so no history push / notify (would loop). Cheap when
4125
- * nothing moved (signature short-circuit).
4832
+ * Derived elbow-route cache, keyed by link the source of truth for
4833
+ * A*-routed corners, living OUTSIDE the immutable `Scene`. Each entry holds
4834
+ * the routed interior `points` (between from/to) plus the `sig` of the
4835
+ * inputs it was computed from (endpoint refs + bound-shape bounds +
4836
+ * fixedSegments), so an unchanged link short-circuits the reroute.
4837
+ *
4838
+ * `rerouteElbows` still MIRRORS `points` onto `Link.routedPoints` in
4839
+ * `_scene` because three readers still consume the baked field: the render
4840
+ * path (`getLinkPath` in `renderer-core`), the headless `getLinkPath`, and
4841
+ * serialization (`schema.ts`). The mirror is compat-only derived state no
4842
+ * history push / notify.
4843
+ *
4844
+ * TODO(fable R7a): drop the `_scene` mirror once (a) the render path reads
4845
+ * routes from this cache via the `RenderSnapshot`, and (b) headless
4846
+ * `getLinkPath` / serialization stop depending on baked `routedPoints`.
4847
+ * That eviction changes headless geometry, serialized output, and
4848
+ * collab-synced fields, so it spans the scene / serialization / renderer /
4849
+ * headless goldens and must land as its own cross-package change — out of
4850
+ * scope for this state-only pass.
4851
+ */
4852
+ elbowRoutes = new Map();
4853
+ /**
4854
+ * Choke-point reroute (standard model): recompute the route for every
4855
+ * orthogonal link whose inputs changed since the last pass, store it in the
4856
+ * derived {@link elbowRoutes} cache, and mirror it onto `_scene`. Runs once
4857
+ * per frame before paint — derived state, so no history push / notify (would
4858
+ * loop). Cheap when nothing moved (signature short-circuit).
4126
4859
  */
4127
4860
  rerouteElbows() {
4128
4861
  let next = this._scene;
@@ -4130,22 +4863,140 @@ export class Editor {
4130
4863
  if ((edge.routing ?? "straight") !== "orthogonal")
4131
4864
  continue;
4132
4865
  const sig = elbowSignature(this._scene, edge);
4133
- if (this.elbowRouteSig.get(id) === sig)
4866
+ if (this.elbowRoutes.get(id)?.sig === sig)
4134
4867
  continue;
4135
- this.elbowRouteSig.set(id, sig);
4136
- const routedPoints = routeElbowLink(next, edge);
4137
- next = updateLink(next, id, (e) => ({ ...e, routedPoints })).scene;
4868
+ const points = routeElbowLink(next, edge);
4869
+ this.elbowRoutes.set(id, { sig, points });
4870
+ // Compat mirror onto the scene (see `elbowRoutes` doc).
4871
+ next = updateLink(next, id, (e) => ({ ...e, routedPoints: points })).scene;
4138
4872
  }
4139
4873
  this._scene = next;
4140
4874
  }
4875
+ /**
4876
+ * Collect everything {@link renderEditor} paints from into a flat
4877
+ * {@link RenderSnapshot}. Resolves the derived viewport / dirty-rect / dim /
4878
+ * hide inputs and the shared spatial index up front (same order the
4879
+ * orchestrator used to call them in), so the paint pass stays side-effect
4880
+ * free and the orchestrator stays decoupled from this class.
4881
+ *
4882
+ * `computeDirtyWorld` is order-sensitive (it diffs against
4883
+ * `lastRenderedScene` and populates `tileDirtyElements`); it runs here and
4884
+ * the `lastRendered*` bookkeeping is applied in `render()` after the paint.
4885
+ */
4886
+ buildRenderSnapshot() {
4887
+ // Stroke-erase live preview: the touched originals are hidden in the main
4888
+ // pass and their would-be fragments drawn on the overlay.
4889
+ const strokeErasePreview = this.computeStrokeErasePreview();
4890
+ const baseHidden = this.computeHiddenElements();
4891
+ const hideElements = strokeErasePreview === null
4892
+ ? baseHidden
4893
+ : new Set([...(baseHidden ?? []), ...strokeErasePreview.hidden]);
4894
+ return {
4895
+ mainTarget: this.mainTarget,
4896
+ overlayTarget: this.overlayTarget,
4897
+ backgroundTarget: this.backgroundTarget,
4898
+ scene: this._scene,
4899
+ selection: this._selection,
4900
+ selectedLinks: this._selectedLinks,
4901
+ selectedLink: this.selectedLink,
4902
+ selectedAnnotation: this._selectedAnnotation,
4903
+ enteredGroup: this._enteredGroup,
4904
+ gridEnabled: this.gridEnabled,
4905
+ viewportWorld: this.computeViewportWorld(),
4906
+ dirtyWorld: this.computeDirtyWorld(),
4907
+ dimElements: this.computeDimSet(),
4908
+ eraseActive: (this.interaction.eraseStroke?.pending.size ?? 0) > 0,
4909
+ hideElements,
4910
+ strokeErasePreview,
4911
+ sharedIndex: this._scene.elements.size >= LARGE_SCENE_HIT_THRESHOLD ? this.ensureSpatialIndex() : null,
4912
+ boundsCache: this.boundsCache,
4913
+ // Per-instance playback clock threaded through the render context (see
4914
+ // RenderSnapshot.animationClock). Feeds the renderer our per-shape
4915
+ // playback state so paused / reduced-motion GIFs freeze and resumed ones
4916
+ // continue from the right frame — without mutating the process-global
4917
+ // clock each frame, so two editors on one page don't interfere.
4918
+ // A non-string / missing id maps to an untracked key, for which
4919
+ // `clock` falls back to the wall clock.
4920
+ animationClock: (shape) => this.gifPlayback.clock(castElementId(typeof shape.id === "string" ? shape.id : "")),
4921
+ tileComposeFn: this.tileComposeFn,
4922
+ tileDirtyElements: this.tileDirtyElements,
4923
+ mode: this.mode,
4924
+ activeLayerId: this._activeLayerId,
4925
+ cropFrame: this.cropFrameCorners(),
4926
+ cropGhost: this.cropGhost(),
4927
+ flowchartPreview: this.flowchartPreview,
4928
+ lassoPreview: this.lassoPreview,
4929
+ drawingPreview: this.drawingPreview,
4930
+ edgePreview: this.edgePreview,
4931
+ linkDragFromAnchor: this.linkDragFromAnchor,
4932
+ hoveredLinkTarget: this.hoveredLinkTarget,
4933
+ panGesture: this.panGesture,
4934
+ // `pinch` is unset during the constructor's first render; type says
4935
+ // non-null but runtime can be undefined.
4936
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- see above
4937
+ pinchActive: this.pinch?.isActive() ?? false,
4938
+ gestureActive: this.gestureTx !== null,
4939
+ linkEndpointDrag: this.linkEndpointDrag,
4940
+ linkSegmentDrag: this.linkSegmentDrag,
4941
+ linkWaypointDrag: this.linkWaypointDrag,
4942
+ hoverCursorWorld: this.hoverCursorWorld,
4943
+ anchorStartHitSlop: this.anchorStartHitSlop,
4944
+ anchorClickRadius: this.anchorClickRadius,
4945
+ containerHover: this.containerHover,
4946
+ brushStroke: this.brushPreviewStroke,
4947
+ laserStrokes: this.interaction.laserStrokes,
4948
+ eraserTrail: this.interaction.eraserTrail,
4949
+ // Eraser cursor ring: a size-matched circle following the pointer while
4950
+ // the erase tool is active (not read-only). Sourced from `lastPointerWorld`
4951
+ // (updated on every hover AND drag move) — `hoverCursorWorld` is forced
4952
+ // null outside select mode. Radius is the panel's eraser width in SCREEN
4953
+ // px (matches the slider number).
4954
+ eraserCursor: this.mode === "erase" && !this._readOnly && this.lastPointerWorld !== null
4955
+ ? { center: this.lastPointerWorld, radius: this._brushSettings.width }
4956
+ : null,
4957
+ peerCursors: this._peerCursors,
4958
+ peerSelections: this._peerSelections,
4959
+ debugHitZones: this.debugHitZones,
4960
+ readOnly: this._readOnly,
4961
+ groupMoveOrigin: this.groupMoveOrigin,
4962
+ aspectLocked: this.selectionIsAspectLocked(),
4963
+ combinedSelectionBounds: this.combinedSelectionBounds(),
4964
+ editingText: this.editingTextOverlay(),
4965
+ previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
4966
+ isPlaybackPaused: (id) => this.isPlaybackPaused(id),
4967
+ };
4968
+ }
4141
4969
  render() {
4142
4970
  this.rerouteElbows();
4143
- // Feed the renderer's animation clock our per-shape playback state
4144
- // so paused / reduced-motion GIFs freeze and resumed ones continue
4145
- // from the right frame. Set immediately before the synchronous
4146
- // render pass (the shape-renderer has no options channel).
4147
- setAnimationClock((shape) => this.gifPlayback.clock(shape.id));
4148
- renderEditor(this);
4971
+ // Age out expired laser-trail points before the snapshot so the fade
4972
+ // advances every frame and the tick self-terminates once all trails clear.
4973
+ this.pruneLaser();
4974
+ // The per-shape animation clock is threaded per-instance through the
4975
+ // RenderSnapshot / render context (see `buildRenderSnapshot`), not set on
4976
+ // the process-global module clock each frame — so concurrent editors keep
4977
+ // independent playback.
4978
+ const snapshot = this.buildRenderSnapshot();
4979
+ renderEditor(snapshot);
4980
+ // Bookkeeping the orchestrator used to do inline: record what we just
4981
+ // painted (for the next frame's dirty diff / isolation-transition check)
4982
+ // and, on the tile-cache path, clear the consumed dirty set.
4983
+ this.lastRenderedScene = this._scene;
4984
+ this.lastRenderedEnteredGroup = this._enteredGroup;
4985
+ this.lastRenderedEraseActive = snapshot.eraseActive;
4986
+ // The forced erase repaint (if any) has now happened — later idle frames
4987
+ // (cursor moving / trail fading) skip the full main pass until the next cut.
4988
+ this.eraseDirty = false;
4989
+ // Clear the accumulated tile-dirty set only when the tile path actually
4990
+ // composited this frame. Group isolation (dim) / per-element hide make the
4991
+ // orchestrator fall back to the full renderScene path (it can't reproduce
4992
+ // dim/hide on cached tiles), so on those frames the pending invalidations
4993
+ // must survive to be applied when the tile path resumes — mirror the same
4994
+ // condition here.
4995
+ const isolationActive = (snapshot.dimElements !== undefined && snapshot.dimElements.size > 0) ||
4996
+ (snapshot.hideElements !== undefined && snapshot.hideElements.size > 0);
4997
+ if (snapshot.tileComposeFn && snapshot.viewportWorld && !isolationActive) {
4998
+ this.tileDirtyElements = new Map();
4999
+ }
4149
5000
  // Present AFTER the paint, on the same tick — deferred-submission
4150
5001
  // surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
4151
5002
  this.onAfterRender?.();