@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,163 @@
1
1
  # @oh-just-another/state
2
2
 
3
+ ## 0.60.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 783749e: Brush strokes can now be closed and filled. When a fill colour is set in the drawing panel and a stroke's end is drawn back near its start (within `BRUSH_CLOSE_DISTANCE`), the committed `BrushElement` gets `closed: true` and the renderer fills the enclosed area with `style.fill` under the variable-width stroke body. Open strokes and strokes drawn without a fill colour are unchanged. `BrushElement.closed` is serialized.
8
+ - 641842b: Brush strokes now carry host-controlled paint settings instead of a hard-coded colour. `editor.brushSettings` / `editor.setBrushSettings({ stroke, fill, opacity, width })` set the line colour, enclosed-fill colour (for a future closed-stroke fill), opacity, and base width; a committed stroke bakes them into its style, and the width drives the pressure curve. The brush renderer now paints the line from `style.stroke` (falling back to `style.fill` for strokes authored before the split), so old strokes are unchanged. Fixes the previously hard-coded `#222` brush colour.
9
+ - 0d3934e: Eraser gains Alt-restore. While sweeping the eraser, holding Alt un-marks shapes you drag back over — rescuing them before the delete commits on pointer-up (`extendEraseStroke(world, restore)` / `beginEraseStroke(world, restore)`). Marked-for-erase shapes now preview at a dedicated `ERASE_DIM_OPACITY` (0.2 — a clear "about to delete") instead of the gentler group-isolation dim.
10
+ - 571f13b: The eraser now shows a dedicated cursor instead of the generic crosshair: a grey ring that follows the pointer, sized to the panel's eraser width (`brushSettings.width`), plus a short fading grey trail while you drag. The OS cursor is hidden in erase mode (`cursor: none`) and the ring/trail are painted on the overlay so any radius composites cleanly. The laser and eraser trails now share one `drawFadingTrail` renderer. New `CursorRole` `"erase"`.
11
+ - ca48e8a: Add two interaction tools: an eraser (mode `erase`, hotkey `E`) and a laser pointer (mode `laser`, hotkey `K`).
12
+ - Eraser: press-and-drag sweeps shapes under the cursor into a pending set (previewed dimmed) and deletes them all in one undo step on release. Attached links are removed with their shapes, like a Delete-key delete.
13
+ - Laser pointer: press-and-drag paints an ephemeral red trail that fades over a couple of seconds. Nothing is written to the scene or history — it lives purely on the overlay. Available in read-only mode. Collab replication of trails is a follow-up.
14
+
15
+ Both tools appear in the default toolbar (`DEFAULT_TOOLBAR` / `DEFAULT_VERTICAL_TOOLBAR`) and are registered as `mode-erase` / `mode-laser` actions. TTL, colour and width of the laser trail are tunable via `state/constants.ts`.
16
+
17
+ - bdc847e: Add four editor tools:
18
+ - **Eyedropper** — new `eyedropper` mode (toolbar button, `Alt+I`). Click a
19
+ shape to sample its fill/stroke and apply it to the selection.
20
+ `Editor.pickColorAt` / `applyEyedropperAt` + pure `pickColorAt`.
21
+ - **Convert element type** — `Editor.convertSelection(target)` and pure
22
+ `computeConvertType` switch rectangle ↔ ellipse ↔ diamond (polygon) in place,
23
+ preserving position/size/style. New "Shape type" property-panel control.
24
+ - **Image crop** — optional normalised `crop` rect on `ImageElement` (additive,
25
+ serialised). New `crop` mode entered by double-clicking an image or the
26
+ property-panel Crop button: drag a frame, `Enter` to apply, `Esc` to cancel.
27
+ Canvas2D renders the cropped source region (`RenderTarget.drawImage` gains an
28
+ optional `crop` arg). `Editor.beginImageCrop` / `commitImageCrop` /
29
+ `cancelImageCrop` + pure `computeSetImageCrop` / `cropRectFromWorldDrag`.
30
+ - **Flowchart auto-generate** — `Cmd/Ctrl+Alt+Arrow` spawns a connected node
31
+ from the selected node in that direction. `Editor.spawnConnectedNode` + pure
32
+ `computeSpawnConnectedNode`.
33
+
34
+ Also exposes `worldToLocal` from `@oh-just-another/scene`.
35
+
36
+ - 511a22a: The eyedropper is no longer a standalone toolbar tool (its palette button and the `Alt+I` hotkey / `mode-eyedropper` action are removed). Instead, every colour picker (`ColorSwatchPicker`) gains an optional pipette button via a new `onEyedrop` prop: clicking it arms `Editor.beginEyedropperPick(onPick)` and the next canvas click samples the colour of the shape under the cursor straight into that swatch — without changing the current tool mode. New editor API: `beginEyedropperPick` and the `isEyedropperArmed` flag; the cursor shows a crosshair while armed.
37
+ - a9558d9: Reworked flowchart keyboard model. Arrow-key bindings are reworked and disambiguated by modifier: `Arrow` nudges (unchanged); `Cmd/Ctrl+Arrow` grows a flowchart CREATE session (each press adds a pending connected sibling, previewed on the overlay, committed as one undo step when Cmd/Ctrl is released, cancelled on Escape); `Alt+Arrow` navigates to the adjacent node (graph neighbour, else spatially nearest); `Cmd/Ctrl+Shift+Arrow` aligns (moved off the old plain `Alt+Arrow`). The old `Cmd/Ctrl+Alt+Arrow` spawn binding is retired.
38
+
39
+ New API: `editor.growFlowchart` / `commitFlowchart` / `cancelFlowchart` / `navigateFlowchart` / `flowchartPreview`, the pure `computeSpawnConnectedNodes`, and `endpointElementId` (scene). `<Diagram>` wires the keyup-commit / Escape-cancel lifecycle for you.
40
+
41
+ - 295f38b: Thread the animated-content playback clock as a per-instance render provider instead of a process-global singleton. `RenderSceneOptions` gains an optional `clock`, forwarded to each shape renderer via `ElementRenderContext.clock` (new `AnimationClock` type export). `Editor` now passes its own per-shape playback clock through the render snapshot, so two editors on one page drive independent GIF playback and no longer overwrite a shared module global every frame. The module-global `setAnimationClock` remains as a documented process-global fallback for context-less paths (headless SVG / worker / PNG export and the tile compositor); behaviour is unchanged when no per-instance clock is supplied. Additive and backwards-compatible.
42
+ - 7f69f29: Add scene text search, a stats/dimensions overlay, and zen mode.
43
+ - `state`: `searchScene(scene, query)` / `elementSearchText(element)` — a pure, case-insensitive substring index over text shapes, frame names, and edge labels; plus `Editor.selectLink(id)` to programmatically select a single connector.
44
+ - `react-ui`: `<SearchOverlay>` (⌘F) finds and frames matching text with next/prev navigation; `<StatsPanel>` (⌥/) shows the selection's x/y/w/h/angle and scene totals; `<ZenModeProvider>` / `useZenMode` (⌥Z, Esc to exit) hides chrome for focused work. All three are wired into `<Editor>` from `@oh-just-another/editor`.
45
+
46
+ - cec8f83: Add read-only / view mode. `EditorOptions.readOnly`, `editor.readOnly`, `editor.setReadOnly()` and `editor.toggleReadOnly()` gate every scene-mutating pointer path (create / move / resize / rotate / annotation / edge edits) at the `applyEmit` choke point and in the pointer-down handlers, while pan / zoom / click + marquee select stay live. The action registry now honours each action's `viewMode` flag — in read-only only `viewMode` actions dispatch (zoom, pan, grid, select-all, cancel, and the new `toggle-read-only` action bound to `⌥R`).
47
+ - 1975a9b: Stroke-eraser: holding Shift while erasing cuts brush strokes into fragments instead of deleting the whole element. Each brush point within the eraser capsule (radius = the on-screen eraser ring in world units, widened by the point's own half-width) is removed; surviving points split into fragment strokes (a lone kept point becomes a dot), links bound to a cut brush are detached, and it all lands in one undo step. A live preview shows the cut while you drag (touched originals hidden, fragments shown). Without Shift the eraser still deletes whole elements; non-brush shapes under Shift fall back to whole-element erase.
48
+ - cf8b735: Add styled text runs (rich text, phase 1): a `TextElement` can now carry an optional `runs` overlay — contiguous substrings each with a partial `TextStyle` (bold / italic / colour / decoration) over the element's base style. The flat `text` stays the source of truth (`runs.map(r => r.text).join("") === text`), so plain-text scenes render, serialise and round-trip byte-for-byte unchanged.
49
+ - `scene`: `TextRun` type + `TextElement.runs?`; pure helpers `runsToText`, `normalizeRuns`, `sliceRuns`, and `applyStyleToRange(el, from, to, partial)` (splits/merges/coalesces runs, sheds the overlay when uniform).
50
+ - `serialization`: additive optional `runs` in the text schema; legacy documents (no `runs`) round-trip unchanged.
51
+ - `renderer-core`: the text renderer draws each visual line's style segments with per-run font + fill through the shared `RenderTarget`, so Canvas2D, WebGL2 and SVG all honour runs. Line breaking still uses the element's base metrics.
52
+ - `state`: `Editor.applyTextStyleToRange(id, from, to, partial)` applies a style to a character range as one undo step.
53
+ - `react-ui`: the text formatting controls (bold / italic / underline / strikethrough / colour) target the current inline-edit selection when one is active — styling just those characters — and fall back to whole-element styling otherwise.
54
+
55
+ Full inline rich-text editing (per-run wrap metrics, caret-aware run editing) is a follow-up.
56
+
57
+ ### Patch Changes
58
+
59
+ - c189261: The live brush-stroke preview is now Catmull-Rom-smoothed with the same resampler `commitBrushStroke` applies on release, so a stroke reads smooth as it's drawn instead of snapping from an angular polyline to a curve only when the pointer is lifted.
60
+ - c189261: The live brush-stroke preview now paints in the chosen palette colour and opacity instead of a hardcoded dark-grey fill, so it matches the committed stroke. The brush body colour resolution is now a single shared `brushBodyColor(style)` helper (exported from `@oh-just-another/scene`) used by both the committed-stroke renderer and the preview, so the two can't drift.
61
+ - c189261: Brush strokes now render as a single closed outline polygon filled once, instead of a chain of per-segment quads plus a disc at every joint. The old approach overlapped itself, so at `opacity < 1` the joins double-blended into dark blotches; the single fill paints every pixel exactly once. Round joins/caps are preserved (arc points on convex corners, mitered concave corners clamped to stay a simple, non-self-intersecting polygon). The outline geometry is a new shared `brushOutline(points)` helper (exported from `@oh-just-another/scene`) used by both the committed-stroke renderer and the live preview.
62
+ - c58054b: Brush strokes are now smoothed on commit: the sparsely-captured pointer polyline is resampled through a Catmull-Rom spline (interpolating per-point width) before it enters the scene, so a freehand line reads as a fluid curve instead of a chain of angular segments. Shares one spline resampler (`smoothStrokePoints`) with the laser trail. Tunable via `BRUSH_SMOOTH_SEGMENTS`.
63
+ - b156869: Rework image cropping to a handle interaction. The crop frame is now the image's visible box: double-click an image to enter crop mode, then drag the 8 edge/corner handles to hide pixels (opposite edge stays fixed, the source is never stretched) or drag the image body to pan the source under the frame. A faint full-image ghost is drawn behind the frame so the hidden regions stay visible. Enter/click-outside commits (one undo step), Escape cancels. Replaces the previous rubber-band "draw a rectangle over the image" model.
64
+
65
+ New pure geometry in `tool-ops` (`computeCropHandleDrag`, `computeCropBodyPan`, `computeCommitImageCrop`, `cropFullImageLocalRect`, `cropHandleWorldPoints`, `CropHandle`) and Editor methods (`cropHandleAtWorld`, `beginImageCropHandle`, `beginImageCropBody`); `imageCropSession` now exposes the pending `{crop, position, width, height}`. The normalised `ImageCrop` data model is unchanged. Removed `cropRectFromWorldDrag`.
66
+
67
+ - b0a9f3b: The eraser cursor disc is now filled solid in the trail colour (fully opaque), with the ring on top — a clear, high-contrast aim target that matches the eraser wake.
68
+ - 1975a9b: The stroke-eraser no longer eats more of a line than the cursor ring shows. A brush point was erased when the eraser capsule reached the stroke's outer EDGE (`radius + point.width`); now it's erased when the ring covers the point's centre (`radius`), which equals the visible cursor ring at every zoom. The eraser removes exactly the centreline it passes over.
69
+ - 1975a9b: Fix the eraser cursor freezing when you pause mid-drag (button held) and then resume. The fading trail could empty during the pause, and the resumed move then had neither an active trail nor an object change, so it never triggered a repaint — the cursor stuck until release, when the whole cut applied at once. The eraser now always repaints on move (so the ring follows the pointer) and restarts the trail if it had faded.
70
+ - 22b90f9: The keyboard-shortcuts help dialog now lists every real binding. The `arrange` category (align / flip / distribute) was missing from the dialog's category order and is now shown, and keyTest-driven bindings (nudge arrows, Enter edit/create, plus flowchart create/navigate) surface their chips via a new display-only `Action.displayHotkey` field instead of rendering as "—". `displayHotkey` is never dispatched (only `hotkey`/`keyTest`/`sequence` are), which also closes a latent hole where a display matcher could fire a Ctrl-modified combo the `keyTest` deliberately excluded.
71
+ - f381039: Clicking a drawn line's (brush stroke's) link-start dot no longer clone-creates a connected element — duplicating a freehand line as a "node" made no sense. The start dots and dragging a real link from a brush stroke are unchanged; only the click-to-clone (and its hover ghost) is suppressed for brush sources. Other shapes keep the spawn-connected-node behaviour.
72
+ - bd2e26c: Make read-only (view) mode a true guard. Every mutating `Editor` method reachable from the UI (`updateStyle`, `updateTextProps`, `deleteSelected`, `duplicateSelected`, group/ungroup, align/flip/distribute, z-order, `moveSelectionBy`, `setLink`, `convertSelection`, `clear`, etc.) is now a no-op while `readOnly` is set, backstopping direct panel/hotkey calls that previously bypassed the pointer-level gate. The overlay keeps the selection outline (halo) but no longer paints resize/rotate/group handles or link endpoint grips in read-only, and the property panel / selection floating panel / mutating context-menu entries are hidden. `copy` / `copy-style` are flagged view-safe so they stay live.
73
+ - 97de2fd: Hide the link-creation overlay entirely in read-only mode. Hovering an element no longer shows connection anchor dots, and hovering a dot no longer previews a ghost element/connector — read-only never creates links, so the whole port/ghost overlay is now gated off. Editable behaviour is unchanged.
74
+ - 71a6c8b: Search navigation no longer blows a small match up to fill the whole canvas. Jumping to a match now centers it while preserving the current zoom, only zooming out when the match is too large to fit — a small element stays small and just lands in the center. Adds `Editor.revealSelection(padding)` and the pure `computeRevealBounds` helper (never zooms in, unlike `zoomToSelection`'s fit-to-fill).
75
+ - dde8279: Perf: memoize the overlay-options bag per overlay target and reuse it across frames whose overlay inputs are identity-unchanged (idle / animation / peer-update frames), rebuilding only on a real state change; GIF "play" badges are still recomputed every frame. Feed the persistent spatial index (shared with the hit-test path) to the tile compositor so large-scene tile rasterisation queries the index instead of scanning every shape per tile. Group isolation (dim) / per-element hide now correctly fall back to the full `renderScene` path when the tile cache is enabled, instead of silently dropping the dim/hide effect. Behaviour is unchanged when no tile cache is used and when no isolation/hide is active.
76
+ - 1975a9b: Two stroke-eraser fixes. (1) No more freeze on a slow or stopped cursor: the whole-scene repaint forced while erasing now happens only on frames that actually mark or cut something, not on every eraser move — a slow/idle cursor generates many pointer events per unit distance, each of which was re-rendering the entire scene. (2) Cutting a stroke no longer leaves isolated single-point dots: lone kept points (a survivor with both neighbours erased) are dropped instead of kept as stray discs.
77
+ - 1975a9b: The stroke-eraser (Shift + erase over a brush) now cuts the stroke's **geometry by arc length** instead of dropping whole vertices. A large eraser that merely grazes a line — or one passing between two far-apart points on a fast/short stroke — removes exactly the span it covers, with the fragment edges pinned to the eraser ring. This fixes the eraser ignoring sparsely-sampled or short strokes and eating a gap unrelated to the disc size.
78
+ - 1975a9b: Stroke-eraser no longer freezes on longer drags. The live cut was recomputed against the entire eraser path every frame (O(points × path length)), so the main thread saturated as the path grew — the cursor froze and the whole cut applied at once on release. Erased points are now accumulated incrementally (each move tests only the new segment, skipping already-erased points), making the per-move cost O(points) and the preview smooth throughout the drag.
79
+ - 571f13b: The laser and eraser trails now render as one filled comet shape per stroke instead of a stack of alpha-blended segments. The smoothed centreline is offset into a single ribbon whose half-width tapers from the head to a pointed tail, filled once at a single opacity that fades by the freshest point's age. This removes the overlapping round-cap "beads" at every joint that made the trail look like a chain of little lasers.
80
+ - Updated dependencies [783749e]
81
+ - Updated dependencies [c189261]
82
+ - Updated dependencies [c189261]
83
+ - Updated dependencies [641842b]
84
+ - Updated dependencies [c189261]
85
+ - Updated dependencies [0d3934e]
86
+ - Updated dependencies [bdc847e]
87
+ - Updated dependencies [a9558d9]
88
+ - Updated dependencies [295f38b]
89
+ - Updated dependencies [cf8b735]
90
+ - @oh-just-another/scene@0.60.0
91
+ - @oh-just-another/renderer-core@0.59.0
92
+ - @oh-just-another/history@0.57.3
93
+
94
+ ## 0.59.0
95
+
96
+ ### Minor Changes
97
+
98
+ - b4b252b: Arrange operations for the selection. **Flip** mirrors the selection about its bounding-box centre — horizontal (`Shift+H`) and vertical (`Shift+V`); a single shape flips about its own centre. **Align** flushes two or more shapes to the left / right / top / bottom edge or the horizontal / vertical centre of their bounding box (`Alt+←/→/↑/↓` for the four edges; centres via the panel / menu). **Distribute** evenly spaces three or more shapes so the gaps between them are equal, on the horizontal (`Alt+H`) or vertical (`Alt+V`) axis, keeping the outermost shapes fixed. All three are available from the selection property panel and the right-click menu. New engine API: `Editor.flipSelection(axis)`, `Editor.alignSelection(edge)`, and `Editor.distributeSelection(axis)`.
99
+ - d20d50a: Copy and paste a shape's visual style. `Cmd/Ctrl+Alt+C` captures the fill / stroke / dash / opacity of the selected shape into an in-editor buffer; `Cmd/Ctrl+Alt+V` applies it to the current selection (one undo step). Also available from the right-click menu. New engine API: `Editor.copySelectionStyle()` / `Editor.pasteSelectionStyle()` and the `hasStyleClipboard` flag.
100
+ - 938e7c8: Increase / decrease the font size of the selected text with `Cmd/Ctrl+Shift+>` and `Cmd/Ctrl+Shift+<`. Each shape steps by a gentle ~10 % (at least 1 px) from its own size, so a mixed selection keeps its relative sizing, clamped to the usable range. New engine API: `Editor.adjustSelectionFontSize(direction)`.
101
+ - 9673846: Grid model rework. The viewport's `gridSize` (spacing that doubled as a hidden/
102
+ shown toggle) is replaced by an explicit `gridEnabled` boolean; spacing is fixed
103
+ at `DEFAULT_GRID_SPACING`. The runtime `gridVisible` flag is removed — grid
104
+ on/off now lives on the scene viewport and persists with it. Scene documents
105
+ migrate v1 → v2 automatically (`gridSize > 0` → `gridEnabled: true`). `<Editor>`
106
+ ships gridless by default; hosts enable the grid per scene.
107
+ - 8f00738: Images (static and animated GIF) now render on the OffscreenCanvas worker backend, matching the Canvas2D / WebGL2 backends. The offscreen command stream now carries `drawImage` as an `ImageBitmap`, and static images are loaded as `ImageBitmap` so they cross the worker boundary. `insertImage` now accepts an `ImageBitmap` handle in addition to `HTMLImageElement`.
108
+ - 3152317: The single-shape selection box now turns with the element: its outline, resize
109
+ handles and rotate grip are drawn on an oriented frame that hugs the rotated
110
+ body instead of its axis-aligned bounding box, and handle hit-testing inverse-
111
+ rotates the cursor into the frame so grabs stay precise. The rotate grip moved
112
+ from above the top edge to the bottom-left corner, just outside the shape.
113
+
114
+ Its placement is now defined per element type as an `AnchorRef` — the same
115
+ vocabulary that positions a shape's custom connection points — via the new
116
+ `registerRotateAnchor(type, anchor)` / `getRotateAnchor(type)` API (default:
117
+ the bottom-left corner). Groups and multi-selections keep their axis-aligned
118
+ box, with the grip likewise at the bottom-left corner.
119
+
120
+ New math helper `vec2.rotateAround(point, pivot, radians)`.
121
+
122
+ - fc47ecc: Resizing a rotated shape now works correctly. Dragging a handle on a rotated
123
+ element resizes it in the element's own (un-rotated) frame and keeps the corner
124
+ opposite the dragged handle fixed in world — the same "the other side stays put"
125
+ feel as for an unrotated shape. Aspect-lock (Shift) and resize-from-centre (Alt)
126
+ are honoured in the rotated frame too. Previously a rotated shape jumped because
127
+ the resize math assumed an axis-aligned box.
128
+ - 8fc6b69: Rotate shapes interactively. A rotate grip floats above the selection (single shape or group); dragging it turns the selection about its bounding-box centre, and holding **Shift** snaps the angle to 15° steps. The engine API `Editor.rotateSelection(angle)` drives the same maths programmatically. Element rotation was already modelled and rendered — this adds the handle, the gesture, and the hit-testing (the grip takes priority over the link-start anchors it overlaps).
129
+ - edde5d0: Add `bindEditorHotkeys(editor, options?)` — a reusable, framework-agnostic keyboard-shortcut binding driven by the action registry. Returns an unbind function, leaves text fields alone (except `Escape`), and reads `composedPath()[0]` so the editable-target check stays correct across a shadow-root boundary. Re-exported from `@oh-just-another/editor`.
130
+ - c5be6e5: Transform modifier keys during pointer gestures: hold **Alt** to resize symmetrically about the element's centre, **Shift** to lock the aspect ratio while resizing, and **Shift** to constrain a move to a single axis (Cmd/Ctrl already pulls a shape off the grid for one gesture). `<Editor>` mirrors the modifiers from keyboard events automatically; headless hosts can drive them via `Editor.setTransformModifiers({ alt, shift })`. Applies to single shapes, multi-selection / group resizes, and text.
131
+
132
+ ### Patch Changes
133
+
134
+ - 0152ed6: The canvas surface now takes keyboard focus on pointer-down. The press handler
135
+ calls `preventDefault()` (to suppress text selection / native scroll), which also
136
+ suppressed the browser's default focus-on-click — so clicking the canvas left it
137
+ unfocused and keyboard shortcuts (or a clean blur of a previously-focused panel
138
+ input) only worked after tabbing to it, reading as "the first click did nothing".
139
+ The handler now focuses the host explicitly, skipping the case where the press
140
+ lands on an in-canvas text field so editing keeps its own focus.
141
+ - f370dba: `normalizeHref` no longer backtracks polynomially on a crafted email-like input:
142
+ the bare-email check matches domain labels linearly. As a side effect it is
143
+ stricter about what counts as an email — a domain with empty labels (consecutive
144
+ dots, e.g. `a@b..c`) is treated as a URL and gets `https://`, not `mailto:`.
145
+ - da91d59: Polish the rotate grip: it now renders as a clockwise circular-arrow glyph (a
146
+ `rotate-cw` icon) instead of a plain circle, and the connector line back to the
147
+ shape is gone. Hovering the grip shows a `grab` cursor; the cursor switches to
148
+ `grabbing` while a rotate gesture is in flight (overridable via the new
149
+ `rotate` cursor role).
150
+ - 1c7cc6c: Fix inline text editing on a scaled text element: the caret and selection highlight now apply the element's `scale`, so they line up with the rendered text instead of trailing behind it. Clicking to place the caret divides the point back through `scale` to hit the right glyph.
151
+ - Updated dependencies [9673846]
152
+ - Updated dependencies [ff90a95]
153
+ - Updated dependencies [3152317]
154
+ - Updated dependencies [f98730f]
155
+ - Updated dependencies [904cc09]
156
+ - @oh-just-another/scene@0.59.0
157
+ - @oh-just-another/renderer-core@0.58.0
158
+ - @oh-just-another/math@0.58.0
159
+ - @oh-just-another/history@0.57.2
160
+
3
161
  ## 0.58.0
4
162
 
5
163
  ### Minor Changes
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # @oh-just-another/state
2
2
 
3
- Level 2 interaction layer. Owns the xstate interaction machine, normalizes DOM events, draws selection/handles on the overlay layer, and ships a high-level `Editor` class that wires a scene + main/overlay render targets + DOM events together.
3
+ [![npm version](https://img.shields.io/npm/v/@oh-just-another/state.svg)](https://www.npmjs.com/package/@oh-just-another/state)
4
4
 
5
- Browser-only relies on the DOM `PointerEvent` API. Depends on `@types`, `@math`, `@scene`, `@renderer-core`, and `xstate` (v5).
5
+ Interactive editor engine: tools, modes, selection, history, and hit-testing over a Scene.
6
+
7
+ The L2 interaction layer. It wires a Scene together with render targets and high-level operations into an `Editor` controller — framework-agnostic, with no React dependency. Depends on `@oh-just-another/types`, `@oh-just-another/math`, `@oh-just-another/scene`, `@oh-just-another/renderer-core`, `@oh-just-another/history`, `@oh-just-another/events`, `@oh-just-another/tokens`, and `xstate` (v5). Hosts feed it normalized pointer/keyboard/wheel events; it never imports the DOM directly.
6
8
 
7
9
  ## Quick start
8
10
 
9
11
  ```ts
10
12
  import { LayeredCanvas, installBuiltinRenderers } from "@oh-just-another/renderer-canvas";
11
- import { Editor, type Mode } from "@oh-just-another/state";
13
+ import { Editor } from "@oh-just-another/state";
12
14
  import { emptyScene } from "@oh-just-another/scene";
13
15
 
14
16
  installBuiltinRenderers();
@@ -24,37 +26,86 @@ const editor = new Editor({
24
26
  initialMode: "select",
25
27
  });
26
28
 
27
- document.querySelector("#rect-button")!.addEventListener("click", () => {
28
- editor.setMode("draw-rect");
29
- });
29
+ editor.subscribe(() => render());
30
+ editor.setMode("draw-rect");
30
31
  ```
31
32
 
32
- ## Architecture
33
+ ## Concepts
34
+
35
+ ### `Editor`
36
+
37
+ The centerpiece controller (`Editor`, `EditorOptions`, `LoadSceneOptions`). It owns the scene, selection, and viewport, and exposes a high-level API spanning:
38
+
39
+ - **Tools / modes** — `mode`, `setMode`, `toolLocked` / `setToolLocked` (sticky tool).
40
+ - **Selection** — `selection`, `selectAll`, `deleteSelected`, `moveSelectionBy`, `focusCycle`, plus link selection (`selectedLinks`, `selectedLink`).
41
+ - **History** — `undo`, `redo`, `canUndo`, `canRedo`, `history` (backed by `@oh-just-another/history`).
42
+ - **Hit-testing** — `hitTest(worldPoint)` returns a `PressTarget`; `hitAnnotation`.
43
+ - **Viewport / camera** — `panBy`, `zoomIn`, `zoomOut`, `zoomAt`, `zoomToFit`, `zoomToSelection`, `setViewportSize`, `screenToWorld`; grid via `gridEnabled`, `setGridVisible`, `toggleGrid`.
44
+ - **Z-order** — `bringToFront`, `sendToBack`.
45
+ - **Grouping / arrange** — `groupSelected`, `ungroup`, `expandSelectionWithDescendants`, `arrangeAsGrid`, `arrangeAsStack`.
46
+ - **Containers / frames** — frame membership reconciliation; `beginFrameNameEdit`, `commitFrameNameEdit`, `cancelFrameNameEdit`.
47
+ - **Link / edge editing** — link routing, endpoint anchors, `linkPreviewPath`, `linkAttachTarget`, `beginLinkCaptionEdit`, `commitLinkCaptionEdit`, `cancelLinkCaptionEdit`, `linkLabelWorld`.
48
+ - **Text editing** — inline caret/selection model: `beginTextEdit`, `commitTextEdit`, `cancelTextEdit`, `setEditingText`, `setTextCaretFromPoint`, `extendTextSelectionToPoint`, `caretIndexAtWorldPoint`, `editingTextOverlay`, plus `createTextAt`.
49
+ - **Image / file insert** — `insertImage`, `addElement`, `beginPlacement`, and a `FileDropRegistry` (see below).
50
+ - **GIF / animation playback** — `togglePlayback`, `hoverAnimatedElement`, `isPlaybackPaused`.
51
+ - **Brush strokes** — `beginBrushStroke`, `extendBrushStroke`, `commitBrushStroke`, `cancelBrushStroke`, `pendingBrushStroke`.
52
+ - **Annotations / comments** — `addAnnotation`, `removeAnnotation`, `toggleAnnotationResolved`, `addComment`, `removeComment`, `setSelectedAnnotation`, `setCommentAuthor`.
53
+ - **Scene lifecycle** — `scene`, `loadScene`.
54
+
55
+ Subscribe via `editor.subscribe(fn)` (coarse-grained) or the typed `EditorEvents` surface (`change`, `mode`, `selection`, `scene`, `history`, `viewport`).
56
+
57
+ ### Modes
58
+
59
+ `Mode` and `DEFAULT_MODE` (`select`). The active mode dictates how a pointer-down is interpreted:
60
+
61
+ `select` · `hand` (pan) · `draw-rect` · `draw-ellipse` · `draw-text` · `draw-edge` · `draw-frame` · `brush`.
62
+
63
+ Pan and zoom remain available as gestures (middle-mouse / Space+drag / wheel) regardless of mode.
64
+
65
+ ### Actions
66
+
67
+ A command registry for editor operations and their hotkeys:
68
+
69
+ - `ActionRegistry`, `defaultActionRegistry`, `registerBuiltinActions`, and types `Action`, `ActionCategory`, `ActionContext`, `HotkeyMatcher`.
70
+ - Predicates `hasSelection`, `hasSelectionOrLink`.
71
+ - Built-ins are exported both as bundles and individually so hosts can replace or compose them: history (`actionUndo`, `actionRedo`, `historyActions`), selection (`actionSelectAll`, `actionDeleteSelection`, `actionDuplicateSelection`, `selectionActions`), clipboard (`actionCopy`, `actionCut`, `actionPaste`, `clipboardActions`), z-order (`actionBringToFront`, `actionSendToBack`, `zOrderActions`), grouping (`actionGroupSelection`, `actionUngroupSelection`, `groupingActions`), zoom (`actionZoomIn`, `actionZoomOut`, `actionZoomReset`, `actionZoomToFit`, `zoomActions`), and modes (`actionModeSelect` … `actionModeFrame`, `actionToggleToolLock`, `actionCancel`, `modeActions`).
72
+
73
+ ### Interaction machine
74
+
75
+ A pure xstate machine (`interactionMachine`) tracks gesture state and emits intent: `InteractionContext`, `InteractionEvent`, `InteractionEmit`, `PressTarget`, the pointer/mode event types, `interpretPressEnd`, `boundsFromPoints`, and `DRAG_THRESHOLD`. It never mutates a scene — emit events describe what the host should apply.
76
+
77
+ ### Selection & handles
78
+
79
+ - `Selection` type plus the `selection.*` namespace of immutable-set helpers.
80
+ - Resize handles: `HandleId`, `ALL_HANDLES`, `HANDLE_SIZE`, `handlePosition`, `hitHandle`, `resizeBounds` (8 zoom-aware corner/edge handles).
81
+
82
+ ### Overlay & peers
83
+
84
+ `renderOverlay`, `OverlayStyle`, `DEFAULT_OVERLAY_STYLE` draw selection outlines, handles, and drawing previews. Collaborative cursors/selections via `PeerCursor`, `PeerSelection`, `Editor.setPeerCursors` / `setPeerSelections`, and `PEER_CURSOR_BROADCAST_INTERVAL_MS`.
85
+
86
+ ### Interactive hit-test registry
87
+
88
+ `registerInteractiveHitTester` / `getInteractiveHitTester` (`InteractiveHitTester`) — an extension point for hit-testing interactive element kinds without the engine knowing their internals.
89
+
90
+ ### File-drop registry
91
+
92
+ `FileDropRegistry` with `FileDropHandler`, `FileDropContext`, `WalkOptions`, plus helpers: `IMAGE_MIME_TYPES`, `VIDEO_MIME_TYPES`, `isImageFile`, `isVideoFile`, `isSceneJsonFile`, `readFileAsDataURL`, `readFileAsText`, `walkDataTransfer`. Hosts register handlers for images, scene JSON, or custom payloads.
93
+
94
+ ### DOM event normalizers
33
95
 
34
- The interaction layer is divided into:
96
+ `fromPointerEvent`, `fromKeyboardEvent`, `fromWheelEvent`, `isEditableTarget` translate raw DOM events into host-side CSS-pixel domain events. Optional: the engine accepts the normalized shapes regardless of source.
35
97
 
36
- - **A pure xstate machine** (`interactionMachine`) that manages the _gesture_ state — what is the user doing right now? It has 5 states: `idle`, `pressing`, `draggingShape`, `draggingHandle`, `drawing`. It never modifies a scene.
37
- - **Emit events** (`InteractionEmit`) describe what the host should do: `SELECT_REPLACE`, `SELECT_CLEAR`, `MOVE_SHAPE`, `RESIZE_SHAPE`, `CREATE_SHAPE`.
38
- - **`Editor`** acts as the glue: it manages the scene + selection, listens for DOM pointer events, performs hit-tests, sends machine events, applies emit events to the scene using `@scene` operations, and re-renders.
98
+ ### Platform detection
39
99
 
40
- This division keeps the state machine completely pure and easy to test, and allows advanced hosts to replace `Editor` if they need more control.
100
+ `isMac`, `isWindows`, `isAndroid`, `isIOS`, `isLinux`, `isFirefox`, `isSafari`, `CTRL_OR_CMD_KEY`, `getDevicePixelRatio`, and the hotkey pretty-printers `formatHotkey`, `formatHotkeyParts` (`PrettyHotkeyDesc`).
41
101
 
42
- ## API
102
+ ### Annotation re-exports
43
103
 
44
- | Name | Purpose |
45
- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
46
- | `Editor` / `EditorOptions` | Top-level controller. Mounts on a host element with two render targets and an initial scene. |
47
- | `Mode` (`select` / `draw-rect` / `draw-ellipse`), `DEFAULT_MODE` | Editor modes. |
48
- | `Selection`, `selection.*` | Immutable `ReadonlySet<ElementId>` + helpers (`single`, `add`, `remove`, `toggle`, `equals`). |
49
- | `HandleId`, `handlePosition`, `hitHandle`, `resizeBounds`, `HANDLE_SIZE` | Resize-handle geometry (8 corner/edge handles, zoom-aware hit-test). |
50
- | `interactionMachine`, `InteractionContext`, `InteractionEvent`, `InteractionEmit`, `PressTarget`, `DRAG_THRESHOLD`, `interpretPressEnd` | Pure xstate machine + types + helpers. |
51
- | `renderOverlay(scene, selection, target, options?)`, `OverlayStyle` | Draws selection outlines, handles, and drawing previews on the overlay layer. |
52
- | `fromPointerEvent` / `fromKeyboardEvent` / `fromWheelEvent` | DOM → domain event normalizers (CSS-pixel coords relative to host element). |
104
+ `Annotation` and `Comment` types are re-exported from `@oh-just-another/scene` so hosts wiring `addAnnotation` / `addComment` don't need a direct scene dependency for the data shapes. `normalizeHref` / `safeHref` sanitize link hrefs.
53
105
 
54
106
  ## Design notes
55
107
 
56
- - **The machine owns gesture state, not scene state.** Selection and elements live in `Editor` (or your own equivalent). Emit events describe intent; the host applies it. This makes the machine snapshot-testable and replayable.
57
- - **Clicks vs drags via threshold.** A press becomes a drag once the pointer travels `DRAG_THRESHOLD` (4 px) from the press origin. Below the threshold, `POINTER_UP` yields a click effect derived via `interpretPressEnd` (SELECT_REPLACE / SELECT_CLEAR).
58
- - **Hit-test order:** handles of currently-selected elements win over element body hits. This matches how every editor feels once an element is selected, clicking its handle resizes rather than re-selects.
59
- - **Handles are screen-sized.** `hitHandle` divides the tolerance by viewport zoom so handles stay 8 × 8 CSS px regardless of zoom level.
60
- - **DOM listeners are pointer-events** (`pointerdown` / `move` / `up` / `cancel`) with `setPointerCapture`. Touch and mouse share a single path; no separate touch handlers.
108
+ - **The machine owns gesture state, not scene state.** Selection and elements live in `Editor`; emit events describe intent and the host applies it, keeping the machine snapshot-testable.
109
+ - **Clicks vs drags via threshold.** A press becomes a drag once the pointer travels `DRAG_THRESHOLD` from the press origin; below it, the press resolves to a click effect via `interpretPressEnd`.
110
+ - **Handles are screen-sized.** `hitHandle` divides tolerance by viewport zoom so handles stay a fixed CSS size at any zoom.
111
+ - **Framework-agnostic.** No React, and no direct DOM access in the engine hosts feed normalized events and own mounting. See [ohjustanother.site](https://ohjustanother.site).