@oh-just-another/state 0.58.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 (272) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +78 -27
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/actions/actionArrange.d.ts +13 -0
  5. package/dist/actions/actionArrange.d.ts.map +1 -0
  6. package/dist/actions/actionArrange.js +130 -0
  7. package/dist/actions/actionArrange.js.map +1 -0
  8. package/dist/actions/actionClipboard.d.ts +2 -0
  9. package/dist/actions/actionClipboard.d.ts.map +1 -1
  10. package/dist/actions/actionClipboard.js +32 -1
  11. package/dist/actions/actionClipboard.js.map +1 -1
  12. package/dist/actions/actionKeyboard.d.ts.map +1 -1
  13. package/dist/actions/actionKeyboard.js +77 -9
  14. package/dist/actions/actionKeyboard.js.map +1 -1
  15. package/dist/actions/actionMode.d.ts +4 -0
  16. package/dist/actions/actionMode.d.ts.map +1 -1
  17. package/dist/actions/actionMode.js +54 -0
  18. package/dist/actions/actionMode.js.map +1 -1
  19. package/dist/actions/actionSelection.d.ts.map +1 -1
  20. package/dist/actions/actionSelection.js +1 -0
  21. package/dist/actions/actionSelection.js.map +1 -1
  22. package/dist/actions/actionText.d.ts +5 -0
  23. package/dist/actions/actionText.d.ts.map +1 -0
  24. package/dist/actions/actionText.js +37 -0
  25. package/dist/actions/actionText.js.map +1 -0
  26. package/dist/actions/actionView.d.ts +6 -0
  27. package/dist/actions/actionView.d.ts.map +1 -1
  28. package/dist/actions/actionView.js +20 -2
  29. package/dist/actions/actionView.js.map +1 -1
  30. package/dist/actions/actionZoom.d.ts.map +1 -1
  31. package/dist/actions/actionZoom.js +5 -0
  32. package/dist/actions/actionZoom.js.map +1 -1
  33. package/dist/actions/index.d.ts +6 -4
  34. package/dist/actions/index.d.ts.map +1 -1
  35. package/dist/actions/index.js +6 -4
  36. package/dist/actions/index.js.map +1 -1
  37. package/dist/actions/registry.d.ts.map +1 -1
  38. package/dist/actions/registry.js +17 -0
  39. package/dist/actions/registry.js.map +1 -1
  40. package/dist/actions/types.d.ts +10 -1
  41. package/dist/actions/types.d.ts.map +1 -1
  42. package/dist/actions/types.js.map +1 -1
  43. package/dist/auto-layout-scheduler.d.ts +1 -3
  44. package/dist/auto-layout-scheduler.d.ts.map +1 -1
  45. package/dist/auto-layout-scheduler.js +1 -2
  46. package/dist/auto-layout-scheduler.js.map +1 -1
  47. package/dist/built-in-handlers.d.ts.map +1 -1
  48. package/dist/built-in-handlers.js +14 -1
  49. package/dist/built-in-handlers.js.map +1 -1
  50. package/dist/clipboard.d.ts +0 -14
  51. package/dist/clipboard.d.ts.map +1 -1
  52. package/dist/clipboard.js +1 -1
  53. package/dist/clipboard.js.map +1 -1
  54. package/dist/constants.d.ts +208 -41
  55. package/dist/constants.d.ts.map +1 -1
  56. package/dist/constants.js +210 -25
  57. package/dist/constants.js.map +1 -1
  58. package/dist/dom-events.d.ts +8 -2
  59. package/dist/dom-events.d.ts.map +1 -1
  60. package/dist/dom-events.js +12 -0
  61. package/dist/dom-events.js.map +1 -1
  62. package/dist/editor/animation-scene.d.ts +36 -0
  63. package/dist/editor/animation-scene.d.ts.map +1 -0
  64. package/dist/editor/animation-scene.js +194 -0
  65. package/dist/editor/animation-scene.js.map +1 -0
  66. package/dist/editor/animation.d.ts +43 -0
  67. package/dist/editor/animation.d.ts.map +1 -0
  68. package/dist/editor/animation.js +80 -0
  69. package/dist/editor/animation.js.map +1 -0
  70. package/dist/editor/applies/arrange.d.ts +46 -0
  71. package/dist/editor/applies/arrange.d.ts.map +1 -0
  72. package/dist/editor/applies/arrange.js +162 -0
  73. package/dist/editor/applies/arrange.js.map +1 -0
  74. package/dist/editor/applies/create.d.ts +0 -6
  75. package/dist/editor/applies/create.d.ts.map +1 -1
  76. package/dist/editor/applies/create.js +1 -1
  77. package/dist/editor/applies/create.js.map +1 -1
  78. package/dist/editor/applies/edge.d.ts +8 -1
  79. package/dist/editor/applies/edge.d.ts.map +1 -1
  80. package/dist/editor/applies/edge.js +31 -1
  81. package/dist/editor/applies/edge.js.map +1 -1
  82. package/dist/editor/applies/link-move.d.ts +0 -8
  83. package/dist/editor/applies/link-move.d.ts.map +1 -1
  84. package/dist/editor/applies/link-move.js +1 -1
  85. package/dist/editor/applies/link-move.js.map +1 -1
  86. package/dist/editor/applies/move.d.ts +6 -0
  87. package/dist/editor/applies/move.d.ts.map +1 -1
  88. package/dist/editor/applies/move.js +6 -0
  89. package/dist/editor/applies/move.js.map +1 -1
  90. package/dist/editor/applies/resize.d.ts +26 -5
  91. package/dist/editor/applies/resize.d.ts.map +1 -1
  92. package/dist/editor/applies/resize.js +91 -13
  93. package/dist/editor/applies/resize.js.map +1 -1
  94. package/dist/editor/caret-blink.d.ts +18 -0
  95. package/dist/editor/caret-blink.d.ts.map +1 -0
  96. package/dist/editor/caret-blink.js +42 -0
  97. package/dist/editor/caret-blink.js.map +1 -0
  98. package/dist/editor/container-ops.d.ts.map +1 -1
  99. package/dist/editor/container-ops.js +2 -2
  100. package/dist/editor/container-ops.js.map +1 -1
  101. package/dist/editor/dirty-rect.d.ts +23 -0
  102. package/dist/editor/dirty-rect.d.ts.map +1 -0
  103. package/dist/editor/dirty-rect.js +115 -0
  104. package/dist/editor/dirty-rect.js.map +1 -0
  105. package/dist/editor/gif-playback.d.ts +45 -0
  106. package/dist/editor/gif-playback.d.ts.map +1 -0
  107. package/dist/editor/gif-playback.js +125 -0
  108. package/dist/editor/gif-playback.js.map +1 -0
  109. package/dist/editor/hit-test.d.ts +3 -3
  110. package/dist/editor/hit-test.d.ts.map +1 -1
  111. package/dist/editor/hit-test.js +40 -15
  112. package/dist/editor/hit-test.js.map +1 -1
  113. package/dist/editor/interaction-state.d.ts +184 -0
  114. package/dist/editor/interaction-state.d.ts.map +1 -0
  115. package/dist/editor/interaction-state.js +139 -0
  116. package/dist/editor/interaction-state.js.map +1 -0
  117. package/dist/editor/link-handle-drag.d.ts +152 -0
  118. package/dist/editor/link-handle-drag.d.ts.map +1 -0
  119. package/dist/editor/link-handle-drag.js +300 -0
  120. package/dist/editor/link-handle-drag.js.map +1 -0
  121. package/dist/editor/long-press.d.ts.map +1 -1
  122. package/dist/editor/long-press.js +2 -2
  123. package/dist/editor/long-press.js.map +1 -1
  124. package/dist/editor/pinch.d.ts.map +1 -1
  125. package/dist/editor/pinch.js +6 -6
  126. package/dist/editor/pinch.js.map +1 -1
  127. package/dist/editor/pointer-binding.d.ts +9 -11
  128. package/dist/editor/pointer-binding.d.ts.map +1 -1
  129. package/dist/editor/pointer-binding.js +1191 -890
  130. package/dist/editor/pointer-binding.js.map +1 -1
  131. package/dist/editor/public/arrange-group.d.ts +3 -9
  132. package/dist/editor/public/arrange-group.d.ts.map +1 -1
  133. package/dist/editor/public/arrange-group.js +4 -6
  134. package/dist/editor/public/arrange-group.js.map +1 -1
  135. package/dist/editor/public/brush.d.ts +31 -12
  136. package/dist/editor/public/brush.d.ts.map +1 -1
  137. package/dist/editor/public/brush.js +66 -17
  138. package/dist/editor/public/brush.js.map +1 -1
  139. package/dist/editor/public/clipboard.d.ts +2 -4
  140. package/dist/editor/public/clipboard.d.ts.map +1 -1
  141. package/dist/editor/public/clipboard.js +2 -4
  142. package/dist/editor/public/clipboard.js.map +1 -1
  143. package/dist/editor/public/cursor.d.ts +29 -0
  144. package/dist/editor/public/cursor.d.ts.map +1 -0
  145. package/dist/editor/public/cursor.js +146 -0
  146. package/dist/editor/public/cursor.js.map +1 -0
  147. package/dist/editor/public/eraser.d.ts +55 -0
  148. package/dist/editor/public/eraser.d.ts.map +1 -0
  149. package/dist/editor/public/eraser.js +65 -0
  150. package/dist/editor/public/eraser.js.map +1 -0
  151. package/dist/editor/public/frame-name.d.ts +20 -0
  152. package/dist/editor/public/frame-name.d.ts.map +1 -0
  153. package/dist/editor/public/frame-name.js +54 -0
  154. package/dist/editor/public/frame-name.js.map +1 -0
  155. package/dist/editor/public/image-insert.d.ts +1 -1
  156. package/dist/editor/public/image-insert.d.ts.map +1 -1
  157. package/dist/editor/public/laser.d.ts +38 -0
  158. package/dist/editor/public/laser.d.ts.map +1 -0
  159. package/dist/editor/public/laser.js +42 -0
  160. package/dist/editor/public/laser.js.map +1 -0
  161. package/dist/editor/public/layers.d.ts.map +1 -1
  162. package/dist/editor/public/layers.js +2 -2
  163. package/dist/editor/public/layers.js.map +1 -1
  164. package/dist/editor/public/link.d.ts +11 -2
  165. package/dist/editor/public/link.d.ts.map +1 -1
  166. package/dist/editor/public/link.js +70 -2
  167. package/dist/editor/public/link.js.map +1 -1
  168. package/dist/editor/public/placement.d.ts +60 -2
  169. package/dist/editor/public/placement.d.ts.map +1 -1
  170. package/dist/editor/public/placement.js +193 -2
  171. package/dist/editor/public/placement.js.map +1 -1
  172. package/dist/editor/public/selection-ops.d.ts +35 -1
  173. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  174. package/dist/editor/public/selection-ops.js +97 -2
  175. package/dist/editor/public/selection-ops.js.map +1 -1
  176. package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
  177. package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
  178. package/dist/editor/public/stroke-eraser-coverage.js +168 -0
  179. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
  180. package/dist/editor/public/stroke-eraser.d.ts +50 -0
  181. package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
  182. package/dist/editor/public/stroke-eraser.js +176 -0
  183. package/dist/editor/public/stroke-eraser.js.map +1 -0
  184. package/dist/editor/public/stroke-smoothing.d.ts +21 -0
  185. package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
  186. package/dist/editor/public/stroke-smoothing.js +37 -0
  187. package/dist/editor/public/stroke-smoothing.js.map +1 -0
  188. package/dist/editor/public/text-edit.d.ts +1 -10
  189. package/dist/editor/public/text-edit.d.ts.map +1 -1
  190. package/dist/editor/public/text-edit.js +2 -16
  191. package/dist/editor/public/text-edit.js.map +1 -1
  192. package/dist/editor/public/tool-ops.d.ts +130 -0
  193. package/dist/editor/public/tool-ops.d.ts.map +1 -0
  194. package/dist/editor/public/tool-ops.js +392 -0
  195. package/dist/editor/public/tool-ops.js.map +1 -0
  196. package/dist/editor/public/z-order.d.ts +1 -12
  197. package/dist/editor/public/z-order.d.ts.map +1 -1
  198. package/dist/editor/public/z-order.js +5 -5
  199. package/dist/editor/public/z-order.js.map +1 -1
  200. package/dist/editor/public/zoom-pan.d.ts +15 -4
  201. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  202. package/dist/editor/public/zoom-pan.js +39 -7
  203. package/dist/editor/public/zoom-pan.js.map +1 -1
  204. package/dist/editor/render-orchestrator.d.ts +177 -5
  205. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  206. package/dist/editor/render-orchestrator.js +519 -358
  207. package/dist/editor/render-orchestrator.js.map +1 -1
  208. package/dist/editor/resize-helpers.d.ts +21 -5
  209. package/dist/editor/resize-helpers.d.ts.map +1 -1
  210. package/dist/editor/resize-helpers.js +59 -19
  211. package/dist/editor/resize-helpers.js.map +1 -1
  212. package/dist/editor/text-edit.d.ts +136 -0
  213. package/dist/editor/text-edit.d.ts.map +1 -0
  214. package/dist/editor/text-edit.js +328 -0
  215. package/dist/editor/text-edit.js.map +1 -0
  216. package/dist/editor/viewport-helpers.d.ts.map +1 -1
  217. package/dist/editor/viewport-helpers.js +3 -3
  218. package/dist/editor/viewport-helpers.js.map +1 -1
  219. package/dist/editor-events.d.ts +1 -1
  220. package/dist/editor-events.d.ts.map +1 -1
  221. package/dist/editor.d.ts +603 -346
  222. package/dist/editor.d.ts.map +1 -1
  223. package/dist/editor.js +1843 -1630
  224. package/dist/editor.js.map +1 -1
  225. package/dist/file-drop.d.ts.map +1 -1
  226. package/dist/file-drop.js +1 -2
  227. package/dist/file-drop.js.map +1 -1
  228. package/dist/frame-helpers.d.ts.map +1 -1
  229. package/dist/frame-helpers.js +5 -5
  230. package/dist/frame-helpers.js.map +1 -1
  231. package/dist/group-helpers.d.ts.map +1 -1
  232. package/dist/group-helpers.js +4 -4
  233. package/dist/group-helpers.js.map +1 -1
  234. package/dist/handle.d.ts +59 -6
  235. package/dist/handle.d.ts.map +1 -1
  236. package/dist/handle.js +99 -7
  237. package/dist/handle.js.map +1 -1
  238. package/dist/hotkeys-binding.d.ts +26 -0
  239. package/dist/hotkeys-binding.d.ts.map +1 -0
  240. package/dist/hotkeys-binding.js +35 -0
  241. package/dist/hotkeys-binding.js.map +1 -0
  242. package/dist/index.d.ts +9 -3
  243. package/dist/index.d.ts.map +1 -1
  244. package/dist/index.js +10 -3
  245. package/dist/index.js.map +1 -1
  246. package/dist/link-selection.d.ts +3 -5
  247. package/dist/link-selection.d.ts.map +1 -1
  248. package/dist/link-selection.js +3 -12
  249. package/dist/link-selection.js.map +1 -1
  250. package/dist/machine.d.ts +27 -1
  251. package/dist/machine.d.ts.map +1 -1
  252. package/dist/machine.js +38 -0
  253. package/dist/machine.js.map +1 -1
  254. package/dist/modes.d.ts +15 -1
  255. package/dist/modes.d.ts.map +1 -1
  256. package/dist/modes.js.map +1 -1
  257. package/dist/overlay.d.ts +92 -6
  258. package/dist/overlay.d.ts.map +1 -1
  259. package/dist/overlay.js +582 -125
  260. package/dist/overlay.js.map +1 -1
  261. package/dist/search.d.ts +37 -0
  262. package/dist/search.d.ts.map +1 -0
  263. package/dist/search.js +45 -0
  264. package/dist/search.js.map +1 -0
  265. package/dist/selection.d.ts.map +1 -1
  266. package/dist/selection.js +2 -10
  267. package/dist/selection.js.map +1 -1
  268. package/dist/util.d.ts +8 -0
  269. package/dist/util.d.ts.map +1 -0
  270. package/dist/util.js +17 -0
  271. package/dist/util.js.map +1 -0
  272. package/package.json +7 -7
@@ -1,10 +1,10 @@
1
- import { getLink, getLinkPath, getLinkCurvePoints, getLinkWaypointMidpoints, getElement, getElementWorldBounds, getElementOutline, getWorldToScreen, getDropZonesWorld, strokeOutsideExtent, isImage, } from "@oh-just-another/scene";
2
- import { DEFAULT_LOD, renderLinks, renderGrid, renderScene } from "@oh-just-another/renderer-core";
1
+ import { getLink, getLinkPath, getLinkCurvePoints, getLinkWaypointMidpoints, getElement, getElementWorldBounds, getElementOutline, getWorldToScreen, getDropZonesWorld, strokeOutsideExtent, isFrame, isImage, } from "@oh-just-another/scene";
2
+ import { DEFAULT_LOD, renderLinks, renderGrid, renderScene, setAnimationClock, } from "@oh-just-another/renderer-core";
3
3
  import { renderOverlay, paintElementSelectionHalo, } from "../overlay.js";
4
4
  import { anchorOverlayPoints } from "./anchor-points.js";
5
5
  import { hitZoneVisibility } from "./hit-test.js";
6
6
  import { buildElementForCreate, buildEdgePreviewLink } from "./applies/create.js";
7
- import { ANCHOR_DOT_ACTIVE_RADIUS, ANCHOR_DOT_RADIUS, ANCHOR_DOT_HOVER_GROW_RADIUS, ANCHOR_DOT_HOVER_MAX_RADIUS, DEFAULT_SNAP_THRESHOLD, GHOST_PREVIEW_OPACITY, ISOLATION_DIM_OPACITY, LARGE_SCENE_HIT_THRESHOLD, LINK_START_ANCHOR_OUTSET, LINK_ATTACH_ANCHOR_OUTSET, } from "../constants.js";
7
+ import { ANCHOR_DOT_ACTIVE_RADIUS, ANCHOR_DOT_RADIUS, ANCHOR_DOT_HOVER_GROW_RADIUS, ANCHOR_DOT_HOVER_MAX_RADIUS, DEFAULT_SNAP_THRESHOLD, FLOWCHART_PREVIEW_OPACITY, GHOST_PREVIEW_OPACITY, ERASE_DIM_OPACITY, ISOLATION_DIM_OPACITY, LINK_START_ANCHOR_OUTSET, LINK_ATTACH_ANCHOR_OUTSET, } from "../constants.js";
8
8
  /**
9
9
  * Stable throwaway id for the transient shape-draw preview element. Never
10
10
  * enters the scene / history — it exists only for the duration of a single
@@ -13,19 +13,79 @@ import { ANCHOR_DOT_ACTIVE_RADIUS, ANCHOR_DOT_RADIUS, ANCHOR_DOT_HOVER_GROW_RADI
13
13
  const DRAW_PREVIEW_ELEMENT_ID = "__draw-preview__";
14
14
  /** Throwaway id for the live draw-edge connector preview link. */
15
15
  const DRAW_PREVIEW_LINK_ID = "__draw-preview-link__";
16
- /** Index-access helper: throws on out-of-range instead of returning `undefined`. */
17
- const req = (v) => {
18
- if (v === undefined)
19
- throw new Error("packages/state: index out of range");
20
- return v;
16
+ import { req } from "../util.js";
17
+ const overlayMemoByTarget = new WeakMap();
18
+ /**
19
+ * Cheap identity signature of every input the overlay-options build reads.
20
+ * Element-wise `Object.is`-compared against the previous frame's; a full match
21
+ * means the bag would rebuild identically, so the prior object is reused.
22
+ *
23
+ * Reference-stable interaction fields (selection, hover, drag state, peer
24
+ * arrays, scene) compare by identity; primitives by value. Two derived values
25
+ * are deliberately absent — `combinedSelectionBounds` and `aspectLocked` are
26
+ * pure functions of `scene` + `selection` (+ `selectedLinks`), all already
27
+ * here, so their freshly-allocated results can't diverge while those match.
28
+ * `gifBadges` is excluded on purpose (its playback-pause input advances outside
29
+ * the signature) and recomputed every frame. `editingText` (fresh each frame
30
+ * while editing) is included, so the memo simply doesn't engage during text
31
+ * editing — not an idle path.
32
+ */
33
+ const buildOverlaySignature = (e) => [
34
+ e.scene,
35
+ e.selection,
36
+ e.selectedLinks,
37
+ e.selectedLink,
38
+ e.selectedAnnotation,
39
+ e.mode,
40
+ e.activeLayerId,
41
+ e.cropFrame,
42
+ e.cropGhost,
43
+ e.flowchartPreview,
44
+ e.strokeErasePreview,
45
+ e.lassoPreview,
46
+ e.drawingPreview,
47
+ e.edgePreview,
48
+ e.linkDragFromAnchor,
49
+ e.hoveredLinkTarget,
50
+ e.panGesture,
51
+ e.pinchActive,
52
+ e.gestureActive,
53
+ e.linkEndpointDrag,
54
+ e.linkSegmentDrag,
55
+ e.linkWaypointDrag,
56
+ e.hoverCursorWorld,
57
+ e.anchorStartHitSlop,
58
+ e.anchorClickRadius,
59
+ e.containerHover,
60
+ e.brushStroke,
61
+ e.laserStrokes,
62
+ e.eraserTrail,
63
+ e.eraserCursor,
64
+ e.peerCursors,
65
+ e.peerSelections,
66
+ e.debugHitZones,
67
+ e.readOnly,
68
+ e.groupMoveOrigin,
69
+ e.editingText,
70
+ ];
71
+ const overlaySigEqual = (a, b) => {
72
+ if (a.length !== b.length)
73
+ return false;
74
+ for (let i = 0; i < a.length; i++) {
75
+ if (!Object.is(a[i], b[i]))
76
+ return false;
77
+ }
78
+ return true;
21
79
  };
22
80
  /**
23
81
  * Render orchestrator: background grid pass, tile-cache vs full renderScene
24
82
  * path, and the overlay options builder (drawing / lasso preview, edge
25
83
  * preview, hovered ports, group handles, container drop zone, brush stroke,
26
- * edge endpoint drag, peer cursors, annotations). Typed against the full
27
- * `Editor` class via a type-only import erased at runtime; the single call
28
- * site is editor.ts's own private `render()` wrapper.
84
+ * edge endpoint drag, peer cursors, annotations). Consumes a flat
85
+ * {@link RenderSnapshot} the Editor builds each frame no coupling to the
86
+ * `Editor` class. The single call site is editor.ts's own private `render()`
87
+ * wrapper. Side-effect free apart from the paint itself: the caller owns the
88
+ * `lastRendered*` bookkeeping and the tile-dirty reset.
29
89
  */
30
90
  export const renderEditor = (editor) => {
31
91
  // Background layer (grid + selection halo), when the host gave us a
@@ -38,435 +98,536 @@ export const renderEditor = (editor) => {
38
98
  if (editor.backgroundTarget) {
39
99
  // Grid pass also clears the background layer each frame. When the grid is
40
100
  // toggled off, still clear it so no stale grid lingers under the halos.
41
- if (editor.gridVisible)
42
- renderGrid(editor._scene, editor.backgroundTarget);
101
+ if (editor.gridEnabled)
102
+ renderGrid(editor.scene, editor.backgroundTarget);
43
103
  else
44
104
  editor.backgroundTarget.clear();
45
105
  const halos = [];
46
- for (const id of editor._selection) {
47
- const shape = getElement(editor._scene, id);
106
+ for (const id of editor.selection) {
107
+ const shape = getElement(editor.scene, id);
48
108
  if (!shape)
49
109
  continue;
50
110
  const style = shape.style ?? {};
51
111
  halos.push({
52
- loops: getElementOutline(editor._scene, shape),
112
+ loops: getElementOutline(editor.scene, shape),
53
113
  outsetWorld: strokeOutsideExtent(style),
54
114
  });
55
115
  }
56
116
  if (halos.length > 0) {
57
- paintElementSelectionHalo(editor.backgroundTarget, getWorldToScreen(editor._scene.viewport), halos, editor._scene.viewport.zoom || 1);
117
+ paintElementSelectionHalo(editor.backgroundTarget, getWorldToScreen(editor.scene.viewport), halos, editor.scene.viewport.zoom || 1);
58
118
  }
59
119
  }
60
120
  // World-space viewport rect — used by `renderScene` to skip off-screen
61
121
  // shapes. Computed by mapping the screen viewport corners through the
62
122
  // inverse projection. Slightly inflated so geometry near the edge
63
123
  // does not flicker during pan.
64
- const viewportWorld = editor.computeViewportWorld();
65
- const dirtyWorld = editor.computeDirtyWorld();
66
- const dimElements = editor._enteredGroup
67
- ? editor.computeDimElements(editor._enteredGroup)
68
- : undefined;
69
- const hideElements = editor.computeHiddenElements();
70
- if (editor.tileComposeFn && viewportWorld) {
71
- // Tile-cache path: clear main once, then composite cached tiles. Dim /
72
- // hide sets aren't honoured by the tile cache (would require a separate
73
- // pass); this opt-in path is intended for very-large static scenes where
74
- // neither typically applies.
124
+ const viewportWorld = editor.viewportWorld;
125
+ const dirtyWorld = editor.dirtyWorld;
126
+ const dimElements = editor.dimElements;
127
+ const hideElements = editor.hideElements;
128
+ // Group isolation (dim) and per-element hide can't be honoured by the tile
129
+ // cache: tiles bake every shape at full opacity, so neither a scrim nor an
130
+ // overlay can reproduce `renderScene`'s per-element dim, nor un-bake a hidden
131
+ // shape, without re-rasterising the affected tiles which would tie the
132
+ // cache to isolation state and defeat it. So while either set is non-empty
133
+ // we take the full `renderScene` path (which honours both). With both empty
134
+ // the intended very-large-static-scene case — the tile path runs and
135
+ // behaves exactly as before.
136
+ const isolationActive = (dimElements !== undefined && dimElements.size > 0) ||
137
+ (hideElements !== undefined && hideElements.size > 0);
138
+ if (editor.tileComposeFn && viewportWorld && !isolationActive) {
139
+ // Tile-cache path: clear main once, then composite cached tiles.
140
+ // The compositor rasterises tiles through its own `renderScene` calls,
141
+ // which we can't hand a render context — bridge this editor's per-instance
142
+ // clock onto the process-global fallback so animated tiles still honour
143
+ // per-shape playback. Scoped to this opt-in path (rare); the common paths
144
+ // no longer touch the module global.
145
+ setAnimationClock(editor.animationClock);
75
146
  editor.mainTarget.clear();
76
- editor.tileComposeFn(editor._scene, editor.mainTarget, {
147
+ // Feed the persistent spatial index (shared with the hit-test path) when
148
+ // the editor keeps one, so the compositor's per-tile element selection
149
+ // queries the index instead of scanning every shape in every layer.
150
+ editor.tileComposeFn(editor.scene, editor.mainTarget, {
77
151
  viewport: viewportWorld,
78
152
  changedElements: editor.tileDirtyElements,
79
- zoomBucket: editor._scene.viewport.zoom > 0
80
- ? 2 ** Math.round(Math.log2(editor._scene.viewport.zoom))
81
- : 1,
153
+ zoomBucket: editor.scene.viewport.zoom > 0 ? 2 ** Math.round(Math.log2(editor.scene.viewport.zoom)) : 1,
154
+ ...(editor.sharedIndex ? { index: editor.sharedIndex } : {}),
82
155
  });
83
- editor.tileDirtyElements = new Map();
84
- renderLinks(editor._scene, editor.mainTarget, { viewportWorld });
156
+ renderLinks(editor.scene, editor.mainTarget, { viewportWorld });
85
157
  }
86
158
  else {
87
159
  // For very large scenes share the same SpatialGrid the hit-test path
88
160
  // already maintains — `renderScene` uses it to skip the per-shape AABB
89
161
  // cull on shapes outside the viewport.
90
- const sharedIndex = editor._scene.elements.size >= LARGE_SCENE_HIT_THRESHOLD ? editor.ensureSpatialIndex() : null;
91
- renderScene(editor._scene, editor.mainTarget, {
162
+ const sharedIndex = editor.sharedIndex;
163
+ renderScene(editor.scene, editor.mainTarget, {
92
164
  ...(viewportWorld ? { viewport: viewportWorld } : {}),
93
165
  ...(dirtyWorld ? { dirtyWorld } : {}),
94
166
  boundsCache: editor.boundsCache,
167
+ clock: editor.animationClock,
95
168
  lod: DEFAULT_LOD,
96
- ...(dimElements ? { dimElements, dimOpacity: ISOLATION_DIM_OPACITY } : {}),
169
+ ...(dimElements
170
+ ? {
171
+ dimElements,
172
+ dimOpacity: editor.eraseActive ? ERASE_DIM_OPACITY : ISOLATION_DIM_OPACITY,
173
+ }
174
+ : {}),
97
175
  ...(hideElements ? { hideElements } : {}),
98
176
  ...(sharedIndex ? { spatialIndex: sharedIndex } : {}),
99
177
  });
100
- renderLinks(editor._scene, editor.mainTarget, {
178
+ renderLinks(editor.scene, editor.mainTarget, {
101
179
  ...(viewportWorld ? { viewportWorld } : {}),
102
180
  ...(dirtyWorld ? { dirtyWorld } : {}),
103
181
  });
104
182
  }
105
- editor.lastRenderedScene = editor._scene;
106
- editor.lastRenderedEnteredGroup = editor._enteredGroup;
107
- const overlayOpts = {};
108
- // Throwaway scene holding the click-create ghost connector rendered
109
- // through the real link renderer (faded) AFTER the overlay, so the ghost
110
- // connector matches the link that will be created (routing / arrowhead /
111
- // style), not a dashed preview line. Set in the start-dot hover branch.
112
- let ghostScene = null;
113
- // The lasso (select-mode rubber-band) keeps the plain dashed rect. A shape
114
- // draw (draw-rect / draw-ellipse) shows a preview of the would-be element
115
- // rendered through its real renderer the user sees the actual shape +
116
- // default style they'll get on release, not just a dashed box. Both
117
- // gestures can't run at once, so one slot is set.
118
- if (editor.lassoPreview) {
119
- overlayOpts.drawingPreview = editor.lassoPreview;
120
- }
121
- else if (editor.drawingPreview) {
122
- const kind = editor.mode === "draw-rect" ? "rect" : editor.mode === "draw-ellipse" ? "ellipse" : null;
123
- if (kind) {
124
- overlayOpts.drawingPreviewElement = buildElementForCreate(editor._scene, kind, editor.drawingPreview, DRAW_PREVIEW_ELEMENT_ID, editor._activeLayerId, () => "");
183
+ // Overlay options bag — rebuilding it allocates dozens of objects (port
184
+ // sets, link halos, handle midpoints, debug zones). On idle / animation /
185
+ // peer-update frames none of its inputs changed, so memoize the whole bag by
186
+ // a cheap identity signature per overlay target and reuse the prior object.
187
+ // `gifBadges` whose input (per-shape GIF playback pause) advances on its
188
+ // own timeline, outside the signature is recomputed every frame below.
189
+ const overlaySig = buildOverlaySignature(editor);
190
+ let overlayMemo = overlayMemoByTarget.get(editor.overlayTarget);
191
+ if (overlayMemo === undefined || !overlaySigEqual(overlayMemo.sig, overlaySig)) {
192
+ const overlayOpts = {};
193
+ // Read-only: keep selection outlines but drop every interactive handle.
194
+ if (editor.readOnly)
195
+ overlayOpts.readOnly = true;
196
+ // Image-crop chrome (crop mode) — dashed accent quad over the pending
197
+ // window, 8 grab handles, and the faint full-image ghost behind them.
198
+ if (editor.cropFrame)
199
+ overlayOpts.cropFrame = editor.cropFrame;
200
+ if (editor.cropGhost)
201
+ overlayOpts.cropGhost = editor.cropGhost;
202
+ // Throwaway scene holding the click-create ghost connector rendered
203
+ // through the real link renderer (faded) AFTER the overlay, so the ghost
204
+ // connector matches the link that will be created (routing / arrowhead /
205
+ // style), not a dashed preview line. Set in the start-dot hover branch.
206
+ let ghostScene = null;
207
+ // The lasso (select-mode rubber-band) keeps the plain dashed rect. A shape
208
+ // draw (draw-rect / draw-ellipse) shows a preview of the would-be element
209
+ // rendered through its real renderer — the user sees the actual shape +
210
+ // default style they'll get on release, not just a dashed box. Both
211
+ // gestures can't run at once, so one slot is set.
212
+ if (editor.lassoPreview) {
213
+ overlayOpts.drawingPreview = editor.lassoPreview;
125
214
  }
126
- else {
127
- overlayOpts.drawingPreview = editor.drawingPreview;
215
+ else if (editor.drawingPreview) {
216
+ const kind = editor.mode === "draw-rect" ? "rect" : editor.mode === "draw-ellipse" ? "ellipse" : null;
217
+ if (kind) {
218
+ overlayOpts.drawingPreviewElement = buildElementForCreate(editor.scene, kind, editor.drawingPreview, DRAW_PREVIEW_ELEMENT_ID, editor.activeLayerId, () => "");
219
+ }
220
+ else {
221
+ overlayOpts.drawingPreview = editor.drawingPreview;
222
+ }
128
223
  }
129
- }
130
- // Draw-edge connector preview: render the would-be link through the real
131
- // link renderer (solid, default arrowheads, full colour) so the dragged
132
- // preview looks exactly like the link that'll be created — same default
133
- // object as commit (`buildLinkForCreate`), not a faded/dashed stand-in.
134
- let edgePreviewScene = null;
135
- if (editor.edgePreview) {
136
- const previewLink = buildEdgePreviewLink(editor._scene, editor.edgePreview, DRAW_PREVIEW_LINK_ID, editor._activeLayerId);
137
- edgePreviewScene = {
138
- ...editor._scene,
139
- links: new Map([[DRAW_PREVIEW_LINK_ID, previewLink]]),
140
- };
141
- }
142
- // Connection anchors. Two roles: link-start (on selection) and link-attach
143
- // (on hover/proximity). During a drag started FROM a start-anchor (select
144
- // mode, no tool switch) BOTH are shown: the source keeps its start dots
145
- // while the target shows its attach dots under the cursor.
146
- if (editor.mode !== "brush" && editor.mode !== "hand") {
147
- const zoom = editor._scene.viewport.zoom || 1;
148
- // Build one overlay port-set for a shape. The free outline-attach point
149
- // (`outlinePoint`, link-attach only) is appended un-offset — it is the
150
- // real landing point. `activeAnchorName` highlights the snap target if it
151
- // is one of the named dots.
152
- const buildPortSet = (shapeId, role, activeAnchorName, outlinePoint) => {
153
- const shape = getElement(editor._scene, shapeId);
154
- if (!shape)
155
- return null;
156
- const outsetPx = role === "link-start" ? LINK_START_ANCHOR_OUTSET : LINK_ATTACH_ANCHOR_OUTSET;
157
- const { names, worldPoints: anchorPts } = anchorOverlayPoints(shape, outsetPx / zoom);
158
- const worldPoints = [...anchorPts];
159
- const namedActive = activeAnchorName !== null ? names.indexOf(activeAnchorName) : -1;
160
- if (role === "link-attach" && outlinePoint)
161
- worldPoints.push(outlinePoint);
162
- const activeIndex = namedActive >= 0
163
- ? namedActive
164
- : role === "link-attach" && outlinePoint
165
- ? worldPoints.length - 1
166
- : -1;
167
- return {
168
- worldPoints,
169
- ...(activeIndex >= 0 ? { activeIndex } : {}),
170
- role,
224
+ // Draw-edge connector preview: render the would-be link through the real
225
+ // link renderer (solid, default arrowheads, full colour) so the dragged
226
+ // preview looks exactly like the link that'll be created — same default
227
+ // object as commit (`buildLinkForCreate`), not a faded/dashed stand-in.
228
+ let edgePreviewScene = null;
229
+ if (editor.edgePreview) {
230
+ const previewLink = buildEdgePreviewLink(editor.scene, editor.edgePreview, DRAW_PREVIEW_LINK_ID, editor.activeLayerId);
231
+ edgePreviewScene = {
232
+ ...editor.scene,
233
+ links: new Map([[DRAW_PREVIEW_LINK_ID, previewLink]]),
171
234
  };
172
- };
173
- const portSets = [];
174
- if (editor.linkDragFromAnchor) {
175
- // Drag from a start-anchor: keep the source's start dots visible…
176
- const startSet = buildPortSet(editor.linkDragFromAnchor.fromElement, "link-start", null);
177
- if (startSet)
178
- portSets.push(startSet);
179
- // …and show the target's attach dots under the cursor.
180
- if (editor.hoveredLinkTarget) {
235
+ }
236
+ // Flowchart-create preview: the pending nodes are drawn through the overlay
237
+ // (faded, `flowchartPreviewElements`); the pending links are drawn through
238
+ // the real link renderer onto a throwaway scene that also carries the
239
+ // pending nodes so both endpoints resolve. Both at reduced opacity below.
240
+ let flowchartPreviewScene = null;
241
+ if (editor.flowchartPreview && editor.flowchartPreview.elements.length > 0) {
242
+ overlayOpts.flowchartPreviewElements = editor.flowchartPreview.elements;
243
+ const previewElements = new Map(editor.scene.elements);
244
+ for (const el of editor.flowchartPreview.elements)
245
+ previewElements.set(el.id, el);
246
+ const previewLinks = new Map();
247
+ for (const link of editor.flowchartPreview.links)
248
+ previewLinks.set(link.id, link);
249
+ flowchartPreviewScene = {
250
+ ...editor.scene,
251
+ elements: previewElements,
252
+ links: previewLinks,
253
+ };
254
+ }
255
+ // Stroke-erase preview: the would-be brush fragments for every touched
256
+ // brush, drawn per-element on the overlay (the originals are hidden in the
257
+ // main pass). Uses the `flowchartPreviewElements`-style per-element paint —
258
+ // NOT `renderScene`, which clears the overlay and would wipe the eraser
259
+ // cursor ring / trail mid-drag.
260
+ if (editor.strokeErasePreview && editor.strokeErasePreview.elements.length > 0) {
261
+ overlayOpts.strokeErasePreviewElements = editor.strokeErasePreview.elements;
262
+ }
263
+ // Connection anchors. Two roles: link-start (on selection) and link-attach
264
+ // (on hover/proximity). During a drag started FROM a start-anchor (select
265
+ // mode, no tool switch) BOTH are shown: the source keeps its start dots
266
+ // while the target shows its attach dots under the cursor.
267
+ // Read-only never creates links, so the whole port/ghost overlay is off —
268
+ // no anchor dots on hover, no click-create ghost element/connector.
269
+ if (editor.mode !== "brush" && editor.mode !== "hand" && !editor.readOnly) {
270
+ const zoom = editor.scene.viewport.zoom || 1;
271
+ // Build one overlay port-set for a shape. The free outline-attach point
272
+ // (`outlinePoint`, link-attach only) is appended un-offset — it is the
273
+ // real landing point. `activeAnchorName` highlights the snap target if it
274
+ // is one of the named dots.
275
+ const buildPortSet = (shapeId, role, activeAnchorName, outlinePoint) => {
276
+ const shape = getElement(editor.scene, shapeId);
277
+ if (!shape)
278
+ return null;
279
+ const outsetPx = role === "link-start" ? LINK_START_ANCHOR_OUTSET : LINK_ATTACH_ANCHOR_OUTSET;
280
+ const { names, worldPoints: anchorPts } = anchorOverlayPoints(shape, outsetPx / zoom);
281
+ const worldPoints = [...anchorPts];
282
+ const namedActive = activeAnchorName !== null ? names.indexOf(activeAnchorName) : -1;
283
+ if (role === "link-attach" && outlinePoint)
284
+ worldPoints.push(outlinePoint);
285
+ const activeIndex = namedActive >= 0
286
+ ? namedActive
287
+ : role === "link-attach" && outlinePoint
288
+ ? worldPoints.length - 1
289
+ : -1;
290
+ return {
291
+ worldPoints,
292
+ ...(activeIndex >= 0 ? { activeIndex } : {}),
293
+ role,
294
+ };
295
+ };
296
+ const portSets = [];
297
+ if (editor.linkDragFromAnchor) {
298
+ // Drag from a start-anchor: keep the source's start dots visible…
299
+ const startSet = buildPortSet(editor.linkDragFromAnchor.fromElement, "link-start", null);
300
+ if (startSet)
301
+ portSets.push(startSet);
302
+ // …and show the target's attach dots under the cursor.
303
+ if (editor.hoveredLinkTarget) {
304
+ const t = editor.hoveredLinkTarget;
305
+ const attachSet = buildPortSet(t.elementId, "link-attach", t.activeAnchor, t.outlinePoint);
306
+ if (attachSet)
307
+ portSets.push(attachSet);
308
+ }
309
+ }
310
+ else if (editor.hoveredLinkTarget) {
311
+ // Proximity snap while a link is drawn with the draw-edge tool.
181
312
  const t = editor.hoveredLinkTarget;
182
313
  const attachSet = buildPortSet(t.elementId, "link-attach", t.activeAnchor, t.outlinePoint);
183
314
  if (attachSet)
184
315
  portSets.push(attachSet);
185
316
  }
186
- }
187
- else if (editor.hoveredLinkTarget) {
188
- // Proximity snap while a link is drawn with the draw-edge tool.
189
- const t = editor.hoveredLinkTarget;
190
- const attachSet = buildPortSet(t.elementId, "link-attach", t.activeAnchor, t.outlinePoint);
191
- if (attachSet)
192
- portSets.push(attachSet);
193
- }
194
- else if (!editor.panGesture &&
195
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `pinch` is unset during the constructor's first render; type says non-null but runtime can be undefined
196
- !editor.pinch?.isActive() &&
197
- !editor.gestureTx && // hide only during a real drag (tx opens on first move-patch), not on a bare press
198
- !editor.edgePreview && // don't show start-anchors if we are already drawing a link
199
- !editor.linkEndpointDrag // or dragging an existing endpoint
200
- ) {
201
- // At rest — show link-start anchors for the single selected element, and
202
- // only while the cursor is over it or within reach of its dots: dots sit
203
- // just OUTSIDE the edges, so the hover zone is the element bounds
204
- // expanded by the outset + dot grab radius. Keeps them visible as the
205
- // cursor travels out to a dot. Dots are NOT shown on unselected
206
- // elements — select first, then connect. The dot nearest the cursor
207
- // grows (`ANCHOR_DOT_HOVER_GROW_RADIUS`).
208
- const cursor = editor.hoverCursorWorld;
209
- if (editor._selection.size === 1 && cursor) {
210
- const id = req([...editor._selection][0]);
211
- const shape = getElement(editor._scene, id);
212
- if (shape) {
213
- const b = getElementWorldBounds(shape);
214
- const pad = (LINK_START_ANCHOR_OUTSET + ANCHOR_DOT_ACTIVE_RADIUS + editor.anchorStartHitSlop) /
215
- zoom;
216
- const near = cursor.x >= b.x - pad &&
217
- cursor.x <= b.x + b.width + pad &&
218
- cursor.y >= b.y - pad &&
219
- cursor.y <= b.y + b.height + pad;
220
- if (near) {
221
- const set = buildPortSet(id, "link-start", null);
222
- if (set) {
223
- const r = ANCHOR_DOT_HOVER_GROW_RADIUS / zoom;
224
- let bestI = -1;
225
- let bestD2 = r * r;
226
- set.worldPoints.forEach((p, i) => {
227
- const dx = p.x - cursor.x;
228
- const dy = p.y - cursor.y;
229
- const d2 = dx * dx + dy * dy;
230
- if (d2 <= bestD2) {
231
- bestD2 = d2;
232
- bestI = i;
317
+ else if (!editor.panGesture &&
318
+ !editor.pinchActive &&
319
+ !editor.gestureActive && // hide only during a real drag (tx opens on first move-patch), not on a bare press
320
+ !editor.edgePreview && // don't show start-anchors if we are already drawing a link
321
+ !editor.linkEndpointDrag // or dragging an existing endpoint
322
+ ) {
323
+ // At rest — show link-start anchors for the single selected element, and
324
+ // only while the cursor is over it or within reach of its dots: dots sit
325
+ // just OUTSIDE the edges, so the hover zone is the element bounds
326
+ // expanded by the outset + dot grab radius. Keeps them visible as the
327
+ // cursor travels out to a dot. Dots are NOT shown on unselected
328
+ // elements select first, then connect. The dot nearest the cursor
329
+ // grows (`ANCHOR_DOT_HOVER_GROW_RADIUS`).
330
+ const cursor = editor.hoverCursorWorld;
331
+ if (editor.selection.size === 1 && cursor) {
332
+ const id = req([...editor.selection][0]);
333
+ const shape = getElement(editor.scene, id);
334
+ if (shape) {
335
+ const b = getElementWorldBounds(shape);
336
+ const pad = (LINK_START_ANCHOR_OUTSET + ANCHOR_DOT_ACTIVE_RADIUS + editor.anchorStartHitSlop) /
337
+ zoom;
338
+ const near = cursor.x >= b.x - pad &&
339
+ cursor.x <= b.x + b.width + pad &&
340
+ cursor.y >= b.y - pad &&
341
+ cursor.y <= b.y + b.height + pad;
342
+ if (near) {
343
+ const set = buildPortSet(id, "link-start", null);
344
+ if (set) {
345
+ const r = ANCHOR_DOT_HOVER_GROW_RADIUS / zoom;
346
+ let bestI = -1;
347
+ let bestD2 = r * r;
348
+ set.worldPoints.forEach((p, i) => {
349
+ const dx = p.x - cursor.x;
350
+ const dy = p.y - cursor.y;
351
+ const d2 = dx * dx + dy * dy;
352
+ if (d2 <= bestD2) {
353
+ bestD2 = d2;
354
+ bestI = i;
355
+ }
356
+ });
357
+ if (bestI >= 0) {
358
+ // Smooth proximity grow: scale the nearest dot from its resting
359
+ // radius up to the max as the cursor closes in (t = 1 at the
360
+ // dot, 0 at the edge of the grow radius).
361
+ const t = r > 0 ? Math.max(0, 1 - Math.sqrt(bestD2) / r) : 0;
362
+ const activeRadius = ANCHOR_DOT_RADIUS + (ANCHOR_DOT_HOVER_MAX_RADIUS - ANCHOR_DOT_RADIUS) * t;
363
+ portSets.push({ ...set, activeIndex: bestI, activeRadius });
364
+ }
365
+ else {
366
+ portSets.push(set);
233
367
  }
234
- });
235
- if (bestI >= 0) {
236
- // Smooth proximity grow: scale the nearest dot from its resting
237
- // radius up to the max as the cursor closes in (t = 1 at the
238
- // dot, 0 at the edge of the grow radius).
239
- const t = r > 0 ? Math.max(0, 1 - Math.sqrt(bestD2) / r) : 0;
240
- const activeRadius = ANCHOR_DOT_RADIUS + (ANCHOR_DOT_HOVER_MAX_RADIUS - ANCHOR_DOT_RADIUS) * t;
241
- portSets.push({ ...set, activeIndex: bestI, activeRadius });
242
- }
243
- else {
244
- portSets.push(set);
245
368
  }
246
- }
247
- // Hovering ON a dot (within the click radius) → ghost preview of
248
- // what a click would create (copy element + connector).
249
- const { names, worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
250
- const clickR2 = (editor.anchorClickRadius / zoom) ** 2;
251
- let hoveredName = null;
252
- for (let i = 0; i < worldPoints.length; i++) {
253
- const p = req(worldPoints[i]);
254
- if ((p.x - cursor.x) ** 2 + (p.y - cursor.y) ** 2 <= clickR2) {
255
- hoveredName = req(names[i]);
256
- break;
369
+ // Hovering ON a dot (within the click radius) → ghost preview of
370
+ // what a click would create (copy element + connector).
371
+ const { names, worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
372
+ const clickR2 = (editor.anchorClickRadius / zoom) ** 2;
373
+ let hoveredName = null;
374
+ for (let i = 0; i < worldPoints.length; i++) {
375
+ const p = req(worldPoints[i]);
376
+ if ((p.x - cursor.x) ** 2 + (p.y - cursor.y) ** 2 <= clickR2) {
377
+ hoveredName = req(names[i]);
378
+ break;
379
+ }
257
380
  }
258
- }
259
- if (hoveredName) {
260
- const preview = editor.previewClickCreate(id, hoveredName);
261
- if (preview) {
262
- overlayOpts.ghostElement = preview.bounds;
263
- overlayOpts.ghostElementShape = preview.element;
264
- ghostScene = preview.ghostScene;
381
+ if (hoveredName) {
382
+ const preview = editor.previewClickCreate(id, hoveredName);
383
+ if (preview) {
384
+ overlayOpts.ghostElement = preview.bounds;
385
+ overlayOpts.ghostElementShape = preview.element;
386
+ ghostScene = preview.ghostScene;
387
+ }
265
388
  }
266
389
  }
267
390
  }
268
391
  }
269
392
  }
393
+ if (portSets.length === 1)
394
+ overlayOpts.ports = req(portSets[0]);
395
+ else if (portSets.length > 1)
396
+ overlayOpts.ports = portSets;
397
+ // Float-attach feedback: when the endpoint will attach to the whole
398
+ // element (not a specific dot), highlight that element so the user knows
399
+ // it'll float vs fix to a point.
400
+ const hov = editor.hoveredLinkTarget;
401
+ if (hov?.mode === "element") {
402
+ const tshape = getElement(editor.scene, hov.elementId);
403
+ if (tshape)
404
+ overlayOpts.linkAttachHighlight = getElementWorldBounds(tshape);
405
+ }
270
406
  }
271
- if (portSets.length === 1)
272
- overlayOpts.ports = req(portSets[0]);
273
- else if (portSets.length > 1)
274
- overlayOpts.ports = portSets;
275
- // Float-attach feedback: when the endpoint will attach to the whole
276
- // element (not a specific dot), highlight that element so the user knows
277
- // it'll float vs fix to a point.
278
- const hov = editor.hoveredLinkTarget;
279
- if (hov?.mode === "element") {
280
- const tshape = getElement(editor._scene, hov.elementId);
281
- if (tshape)
282
- overlayOpts.linkAttachHighlight = getElementWorldBounds(tshape);
407
+ // Group-handle overlay: a multi-object selection (elements + links) OR a
408
+ // single group-typed shape. A lone link keeps its endpoint handles, not a
409
+ // resize box. Aspect-locked groups flag the overlay for corner-only handles.
410
+ if (editor.selection.size + editor.selectedLinks.size > 1 || editor.aspectLocked) {
411
+ const combined = editor.combinedSelectionBounds;
412
+ if (combined)
413
+ overlayOpts.groupBounds = combined;
414
+ if (editor.aspectLocked)
415
+ overlayOpts.groupAspectLocked = true;
283
416
  }
284
- }
285
- // Group-handle overlay: a multi-object selection (elements + links) OR a
286
- // single group-typed shape. A lone link keeps its endpoint handles, not a
287
- // resize box. Aspect-locked groups flag the overlay for corner-only handles.
288
- if (editor._selection.size + editor._selectedLinks.size > 1 || editor.selectionIsAspectLocked()) {
289
- const combined = editor.combinedSelectionBounds();
290
- if (combined)
291
- overlayOpts.groupBounds = combined;
292
- if (editor.selectionIsAspectLocked())
293
- overlayOpts.groupAspectLocked = true;
294
- }
295
- if (editor.containerHover) {
296
- overlayOpts.containerDropZone = editor.containerHover.dropZone;
297
- }
298
- if (editor.brushStroke) {
299
- overlayOpts.brushPreview = {
300
- origin: editor.brushStroke.origin,
301
- points: editor.brushStroke.points,
302
- fill: "#222",
303
- };
304
- }
305
- // Persistent halo around EVERY selected link (multi-select). Curve-aware
306
- // so the halo follows the drawn path, matching the hover highlight.
307
- if (editor._selectedLinks.size > 0) {
308
- const halos = [];
309
- for (const id of editor._selectedLinks) {
310
- const edge = getLink(editor._scene, id);
311
- if (!edge)
312
- continue;
313
- const hpath = getLinkCurvePoints(editor._scene, edge);
314
- if (hpath && hpath.length >= 2) {
315
- halos.push({ path: hpath, width: edge.style.strokeWidth ?? 1 });
417
+ if (editor.containerHover) {
418
+ overlayOpts.containerDropZone = editor.containerHover.dropZone;
419
+ }
420
+ if (editor.brushStroke) {
421
+ // The preview stroke is precomputed by `buildRenderSnapshot`: Catmull-Rom
422
+ // smoothed (same resampler the commit uses) with the real paint colour and
423
+ // opacity, so it matches the committed stroke exactly — no geometry / colour
424
+ // / opacity snap when the pointer is lifted.
425
+ overlayOpts.brushPreview = {
426
+ origin: editor.brushStroke.origin,
427
+ points: editor.brushStroke.points,
428
+ fill: editor.brushStroke.fill,
429
+ opacity: editor.brushStroke.opacity,
430
+ };
431
+ }
432
+ if (editor.laserStrokes.length > 0) {
433
+ overlayOpts.laserStrokes = editor.laserStrokes;
434
+ }
435
+ if (editor.eraserTrail.length > 0) {
436
+ overlayOpts.eraserTrail = editor.eraserTrail;
437
+ }
438
+ if (editor.eraserCursor) {
439
+ overlayOpts.eraserCursor = editor.eraserCursor;
440
+ }
441
+ // Persistent halo around EVERY selected link (multi-select). Curve-aware
442
+ // so the halo follows the drawn path, matching the hover highlight.
443
+ if (editor.selectedLinks.size > 0) {
444
+ const halos = [];
445
+ for (const id of editor.selectedLinks) {
446
+ const edge = getLink(editor.scene, id);
447
+ if (!edge)
448
+ continue;
449
+ const hpath = getLinkCurvePoints(editor.scene, edge);
450
+ if (hpath && hpath.length >= 2) {
451
+ halos.push({ path: hpath, width: edge.style.strokeWidth ?? 1 });
452
+ }
316
453
  }
454
+ if (halos.length > 0)
455
+ overlayOpts.selectedLinkPaths = halos;
317
456
  }
318
- if (halos.length > 0)
319
- overlayOpts.selectedLinkPaths = halos;
320
- }
321
- // Endpoint / bend handles only for the SOLE selected link (no elements);
322
- // a multi/mixed selection hides them to stay uncluttered.
323
- const soleSelectedLink = editor.selectedLink;
324
- if (soleSelectedLink) {
325
- const edge = getLink(editor._scene, soleSelectedLink);
326
- if (edge) {
327
- const path = getLinkPath(editor._scene, edge);
328
- if (path && path.length >= 2) {
329
- // During an endpoint-rebind drag the dragged end is re-pointed live in
330
- // the scene (the whole link follows the cursor), so `path` already
331
- // reflects the cursor position handles ride along with it.
332
- const from = req(path[0]);
333
- const to = req(path[path.length - 1]);
334
- // Bend-point handles: existing waypoints (solid) + segment-midpoint
335
- // "add" handles along the logical [from, ...waypoints, to] chain.
336
- // Midpoints are hidden during an active waypoint drag to declutter.
337
- // ELBOW (orthogonal) links don't expose free bend handles — their
338
- // points are router output, not user-placed (segment-drag editing is
339
- // a separate mechanic). Only straight / bezier show free waypoints.
340
- const isElbow = (edge.routing ?? "straight") === "orthogonal";
341
- if (isElbow) {
342
- // Segment handles on interior segments of the routed chain
343
- // (k in 1..len-3; the two terminal segments touch from/to and
344
- // can't be slid). Hidden during an active segment / endpoint drag.
345
- const midpoints = [];
346
- if (!editor.linkSegmentDrag && !editor.linkEndpointDrag) {
347
- // Straight elbow → one handle on its single segment (grab to
348
- // bend). Routed elbow handles on interior segments.
349
- const segs = path.length === 2
350
- ? [0]
351
- : Array.from({ length: Math.max(0, path.length - 3) }, (_, i) => i + 1);
352
- for (const k of segs) {
353
- const a = req(path[k]);
354
- const b = req(path[k + 1]);
355
- midpoints.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
457
+ // Endpoint / bend handles only for the SOLE selected link (no elements);
458
+ // a multi/mixed selection hides them to stay uncluttered.
459
+ const soleSelectedLink = editor.selectedLink;
460
+ if (soleSelectedLink) {
461
+ const edge = getLink(editor.scene, soleSelectedLink);
462
+ if (edge) {
463
+ const path = getLinkPath(editor.scene, edge);
464
+ if (path && path.length >= 2) {
465
+ // During an endpoint-rebind drag the dragged end is re-pointed live in
466
+ // the scene (the whole link follows the cursor), so `path` already
467
+ // reflects the cursor position handles ride along with it.
468
+ const from = req(path[0]);
469
+ const to = req(path[path.length - 1]);
470
+ // Bend-point handles: existing waypoints (solid) + segment-midpoint
471
+ // "add" handles along the logical [from, ...waypoints, to] chain.
472
+ // Midpoints are hidden during an active waypoint drag to declutter.
473
+ // ELBOW (orthogonal) links don't expose free bend handles — their
474
+ // points are router output, not user-placed (segment-drag editing is
475
+ // a separate mechanic). Only straight / bezier show free waypoints.
476
+ const isElbow = (edge.routing ?? "straight") === "orthogonal";
477
+ if (isElbow) {
478
+ // Segment handles on interior segments of the routed chain
479
+ // (k in 1..len-3; the two terminal segments touch from/to and
480
+ // can't be slid). Hidden during an active segment / endpoint drag.
481
+ const midpoints = [];
482
+ if (!editor.linkSegmentDrag && !editor.linkEndpointDrag) {
483
+ // Straight elbow one handle on its single segment (grab to
484
+ // bend). Routed elbow → handles on interior segments.
485
+ const segs = path.length === 2
486
+ ? [0]
487
+ : Array.from({ length: Math.max(0, path.length - 3) }, (_, i) => i + 1);
488
+ for (const k of segs) {
489
+ const a = req(path[k]);
490
+ const b = req(path[k + 1]);
491
+ midpoints.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
492
+ }
356
493
  }
494
+ overlayOpts.edgeSelection = { from, to, midpoints };
495
+ }
496
+ else {
497
+ const waypoints = [...(edge.waypoints ?? [])];
498
+ // "Add waypoint" handles sit at the VISUAL middle of each span — on
499
+ // the drawn arc for bezier (t=0.5 of the span's cubic), on the chord
500
+ // for straight. The raw chord midpoint would put bezier handles off
501
+ // the curve.
502
+ const midpoints = editor.linkWaypointDrag
503
+ ? []
504
+ : (getLinkWaypointMidpoints(editor.scene, edge) ?? []);
505
+ overlayOpts.edgeSelection = { from, to, waypoints, midpoints };
357
506
  }
358
- overlayOpts.edgeSelection = { from, to, midpoints };
359
507
  }
360
- else {
361
- const waypoints = [...(edge.waypoints ?? [])];
362
- // "Add waypoint" handles sit at the VISUAL middle of each span — on
363
- // the drawn arc for bezier (t=0.5 of the span's cubic), on the chord
364
- // for straight. The raw chord midpoint would put bezier handles off
365
- // the curve.
366
- const midpoints = editor.linkWaypointDrag
367
- ? []
368
- : (getLinkWaypointMidpoints(editor._scene, edge) ?? []);
369
- overlayOpts.edgeSelection = { from, to, waypoints, midpoints };
508
+ }
509
+ }
510
+ if (editor.peerCursors.length > 0)
511
+ overlayOpts.peerCursors = editor.peerCursors;
512
+ if (editor.peerSelections.length > 0)
513
+ overlayOpts.peerSelections = editor.peerSelections;
514
+ if (editor.scene.annotations.size > 0) {
515
+ overlayOpts.annotations = [...editor.scene.annotations.values()];
516
+ overlayOpts.selectedAnnotation = editor.selectedAnnotation;
517
+ }
518
+ // In-canvas text editing: caret + selection highlight for the shape
519
+ // under edit (null when not editing).
520
+ const editingText = editor.editingText;
521
+ if (editingText)
522
+ overlayOpts.editingText = editingText;
523
+ if (editor.debugHitZones) {
524
+ overlayOpts.debugHitZones = true;
525
+ // One decision point for which hit-zone categories are actionable now
526
+ // (see `hitZoneVisibility`): while a link endpoint is being placed only
527
+ // the link-attach drop-zones are live; at rest the resize / body / handle
528
+ // / start-dot zones are. The overlay paints only the visible ones.
529
+ const linkDragActive = editor.linkDragFromAnchor !== null ||
530
+ editor.edgePreview !== null ||
531
+ editor.linkEndpointDrag !== null;
532
+ // An element move is in flight: position snapshot taken (groupMoveOrigin)
533
+ // AND a real drag transaction opened (gestureTx opens only past the drag
534
+ // threshold, so a bare press doesn't count). Resize uses groupResizeOrigin
535
+ // instead, so this stays move-only.
536
+ const elementDragActive = editor.groupMoveOrigin !== null && editor.gestureActive;
537
+ const visibility = hitZoneVisibility({ linkDragActive, elementDragActive });
538
+ overlayOpts.debugHitZoneVisibility = visibility;
539
+ if (visibility.attachDropZones) {
540
+ // Link-attach drop-zones (anchor catchments + edge bands + floating body)
541
+ // the snap engine resolves against, for every element except the drag
542
+ // source — shows where a drop lands ON a point vs ON an edge vs the body.
543
+ const srcId = editor.linkDragFromAnchor?.fromElement;
544
+ const anchors = [];
545
+ const outlineLoops = [];
546
+ for (const shape of editor.scene.elements.values()) {
547
+ if (shape.id === srcId)
548
+ continue;
549
+ anchors.push(...anchorOverlayPoints(shape, 0).worldPoints);
550
+ for (const loop of getElementOutline(editor.scene, shape))
551
+ outlineLoops.push(loop);
370
552
  }
553
+ overlayOpts.debugAttachZones = {
554
+ anchors,
555
+ outlineLoops,
556
+ thresholdWorld: DEFAULT_SNAP_THRESHOLD,
557
+ };
558
+ }
559
+ if (visibility.containerDropZones) {
560
+ // Element drop-zones for ALL frames + containers at once. Frames use
561
+ // centroid-in-body (full world bounds); containers use
562
+ // cursor-in-drop-zone (resolved zone). The dragged elements themselves
563
+ // are skipped — you can't drop into self.
564
+ const dragged = editor.groupMoveOrigin;
565
+ const frames = [];
566
+ const containers = [];
567
+ for (const shape of editor.scene.elements.values()) {
568
+ if (dragged?.has(shape.id))
569
+ continue;
570
+ if (isFrame(shape)) {
571
+ frames.push(getElementWorldBounds(shape));
572
+ continue;
573
+ }
574
+ // All drop-zones (every lane of a multi-lane container), not just the
575
+ // single attach zone — so a swim-lane shows a region per lane.
576
+ containers.push(...getDropZonesWorld(shape));
577
+ }
578
+ overlayOpts.debugContainerZones = { frames, containers };
371
579
  }
372
580
  }
581
+ overlayMemo = {
582
+ sig: overlaySig,
583
+ opts: overlayOpts,
584
+ ghostScene,
585
+ edgePreviewScene,
586
+ flowchartPreviewScene,
587
+ };
588
+ overlayMemoByTarget.set(editor.overlayTarget, overlayMemo);
373
589
  }
374
- if (editor._peerCursors.length > 0)
375
- overlayOpts.peerCursors = editor._peerCursors;
376
- if (editor._peerSelections.length > 0)
377
- overlayOpts.peerSelections = editor._peerSelections;
378
- if (editor._scene.annotations.size > 0) {
379
- overlayOpts.annotations = [...editor._scene.annotations.values()];
380
- overlayOpts.selectedAnnotation = editor._selectedAnnotation;
381
- }
590
+ const overlayOpts = overlayMemo.opts;
382
591
  // "Play" badge on paused animated (GIF) shapes — auto-stopped or held under
383
- // prefers-reduced-motion. Signals a click resumes them.
592
+ // prefers-reduced-motion. Recomputed every frame (and applied onto the
593
+ // possibly-memoized bag): playback pause state is not part of the overlay
594
+ // signature — it advances on its own timeline — so a memo hit must still
595
+ // refresh the badge set.
384
596
  const gifBadges = [];
385
- for (const shape of editor._scene.elements.values()) {
597
+ for (const shape of editor.scene.elements.values()) {
386
598
  if (isImage(shape) && shape.animationKind && editor.isPlaybackPaused(shape.id)) {
387
599
  gifBadges.push(getElementWorldBounds(shape));
388
600
  }
389
601
  }
390
602
  if (gifBadges.length > 0)
391
603
  overlayOpts.gifBadges = gifBadges;
392
- // In-canvas text editing: caret + selection highlight for the shape
393
- // under edit (null when not editing).
394
- const editingText = editor.editingTextOverlay();
395
- if (editingText)
396
- overlayOpts.editingText = editingText;
397
- if (editor.debugHitZones) {
398
- overlayOpts.debugHitZones = true;
399
- // One decision point for which hit-zone categories are actionable now
400
- // (see `hitZoneVisibility`): while a link endpoint is being placed only
401
- // the link-attach drop-zones are live; at rest the resize / body / handle
402
- // / start-dot zones are. The overlay paints only the visible ones.
403
- const linkDragActive = editor.linkDragFromAnchor !== null ||
404
- editor.edgePreview !== null ||
405
- editor.linkEndpointDrag !== null;
406
- // An element move is in flight: position snapshot taken (groupMoveOrigin)
407
- // AND a real drag transaction opened (gestureTx opens only past the drag
408
- // threshold, so a bare press doesn't count). Resize uses groupResizeOrigin
409
- // instead, so this stays move-only.
410
- const elementDragActive = editor.groupMoveOrigin !== null && editor.gestureTx !== null;
411
- const visibility = hitZoneVisibility({ linkDragActive, elementDragActive });
412
- overlayOpts.debugHitZoneVisibility = visibility;
413
- if (visibility.attachDropZones) {
414
- // Link-attach drop-zones (anchor catchments + edge bands + floating body)
415
- // the snap engine resolves against, for every element except the drag
416
- // source — shows where a drop lands ON a point vs ON an edge vs the body.
417
- const srcId = editor.linkDragFromAnchor?.fromElement;
418
- const anchors = [];
419
- const outlineLoops = [];
420
- for (const shape of editor._scene.elements.values()) {
421
- if (shape.id === srcId)
422
- continue;
423
- anchors.push(...anchorOverlayPoints(shape, 0).worldPoints);
424
- for (const loop of getElementOutline(editor._scene, shape))
425
- outlineLoops.push(loop);
426
- }
427
- overlayOpts.debugAttachZones = {
428
- anchors,
429
- outlineLoops,
430
- thresholdWorld: DEFAULT_SNAP_THRESHOLD,
431
- };
432
- }
433
- if (visibility.containerDropZones) {
434
- // Element drop-zones for ALL frames + containers at once. Frames use
435
- // centroid-in-body (full world bounds); containers use
436
- // cursor-in-drop-zone (resolved zone). The dragged elements themselves
437
- // are skipped — you can't drop into self.
438
- const dragged = editor.groupMoveOrigin;
439
- const frames = [];
440
- const containers = [];
441
- for (const shape of editor._scene.elements.values()) {
442
- if (dragged?.has(shape.id))
443
- continue;
444
- if (shape.type === "frame") {
445
- frames.push(getElementWorldBounds(shape));
446
- continue;
447
- }
448
- // All drop-zones (every lane of a multi-lane container), not just the
449
- // single attach zone — so a swim-lane shows a region per lane.
450
- containers.push(...getDropZonesWorld(shape));
451
- }
452
- overlayOpts.debugContainerZones = { frames, containers };
453
- }
454
- }
455
- renderOverlay(editor._scene, editor._selection, editor.overlayTarget, overlayOpts);
604
+ else if (overlayOpts.gifBadges !== undefined)
605
+ delete overlayOpts.gifBadges;
606
+ renderOverlay(editor.scene, editor.selection, editor.overlayTarget, overlayOpts);
456
607
  // Ghost connector (click-create hover) — drawn through the REAL link
457
608
  // renderer onto the overlay, faded, AFTER the overlay chrome so it sits on
458
609
  // top of the ghost element (matches scene z-order: links over shapes). Uses
459
610
  // the would-be link's actual routing / arrowhead / style, not a dashed line.
460
- if (ghostScene) {
611
+ if (overlayMemo.ghostScene) {
461
612
  editor.overlayTarget.save();
462
613
  editor.overlayTarget.setOpacity(GHOST_PREVIEW_OPACITY);
463
- renderLinks(ghostScene, editor.overlayTarget, {});
614
+ renderLinks(overlayMemo.ghostScene, editor.overlayTarget, {});
464
615
  editor.overlayTarget.restore();
465
616
  }
466
617
  // Live draw-edge connector preview — real link renderer, FULL opacity (the
467
618
  // preview must look identical to the committed link, not faded/dashed).
468
- if (edgePreviewScene) {
469
- renderLinks(edgePreviewScene, editor.overlayTarget, {});
619
+ if (overlayMemo.edgePreviewScene) {
620
+ renderLinks(overlayMemo.edgePreviewScene, editor.overlayTarget, {});
621
+ }
622
+ // Flowchart-create connectors — pending links drawn through the real link
623
+ // renderer at reduced opacity (the pending nodes are painted by the overlay's
624
+ // `flowchartPreviewElements` at the same opacity). Drawn last so connectors
625
+ // sit over the nodes, matching scene z-order.
626
+ if (overlayMemo.flowchartPreviewScene) {
627
+ editor.overlayTarget.save();
628
+ editor.overlayTarget.setOpacity(FLOWCHART_PREVIEW_OPACITY);
629
+ renderLinks(overlayMemo.flowchartPreviewScene, editor.overlayTarget, {});
630
+ editor.overlayTarget.restore();
470
631
  }
471
632
  };
472
633
  //# sourceMappingURL=render-orchestrator.js.map