@oh-just-another/state 0.62.0 → 0.63.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 (259) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/actions/registry.js +1 -1
  4. package/dist/actions/registry.js.map +1 -1
  5. package/dist/constants.d.ts +162 -8
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +132 -8
  8. package/dist/constants.js.map +1 -1
  9. package/dist/editor/animation-scene.d.ts.map +1 -1
  10. package/dist/editor/animation-scene.js +27 -39
  11. package/dist/editor/animation-scene.js.map +1 -1
  12. package/dist/editor/animation.js +1 -1
  13. package/dist/editor/animation.js.map +1 -1
  14. package/dist/editor/applies/arrange-units.d.ts +24 -0
  15. package/dist/editor/applies/arrange-units.d.ts.map +1 -0
  16. package/dist/editor/applies/arrange-units.js +56 -0
  17. package/dist/editor/applies/arrange-units.js.map +1 -0
  18. package/dist/editor/applies/arrange.d.ts +16 -15
  19. package/dist/editor/applies/arrange.d.ts.map +1 -1
  20. package/dist/editor/applies/arrange.js +33 -48
  21. package/dist/editor/applies/arrange.js.map +1 -1
  22. package/dist/editor/applies/create.d.ts +16 -10
  23. package/dist/editor/applies/create.d.ts.map +1 -1
  24. package/dist/editor/applies/create.js +32 -12
  25. package/dist/editor/applies/create.js.map +1 -1
  26. package/dist/editor/applies/edge.d.ts +1 -1
  27. package/dist/editor/applies/edge.d.ts.map +1 -1
  28. package/dist/editor/applies/object-snap.d.ts +87 -0
  29. package/dist/editor/applies/object-snap.d.ts.map +1 -0
  30. package/dist/editor/applies/object-snap.js +198 -0
  31. package/dist/editor/applies/object-snap.js.map +1 -0
  32. package/dist/editor/applies/resize.d.ts +1 -1
  33. package/dist/editor/applies/resize.d.ts.map +1 -1
  34. package/dist/editor/applies/resize.js +6 -4
  35. package/dist/editor/applies/resize.js.map +1 -1
  36. package/dist/editor/applies/selection.d.ts +5 -5
  37. package/dist/editor/applies/selection.d.ts.map +1 -1
  38. package/dist/editor/applies/selection.js +15 -7
  39. package/dist/editor/applies/selection.js.map +1 -1
  40. package/dist/editor/applies/snap-grid.d.ts +1 -1
  41. package/dist/editor/applies/snap-grid.d.ts.map +1 -1
  42. package/dist/editor/container-ops.d.ts +1 -1
  43. package/dist/editor/container-ops.d.ts.map +1 -1
  44. package/dist/{editor-events.d.ts → editor/editor-events.d.ts} +2 -2
  45. package/dist/editor/editor-events.d.ts.map +1 -0
  46. package/dist/{editor-events.js.map → editor/editor-events.js.map} +1 -1
  47. package/dist/editor/event-fanout.d.ts +3 -3
  48. package/dist/editor/event-fanout.d.ts.map +1 -1
  49. package/dist/editor/gesture-tx.d.ts +1 -1
  50. package/dist/editor/gesture-tx.d.ts.map +1 -1
  51. package/dist/editor/hit-test.d.ts +5 -3
  52. package/dist/editor/hit-test.d.ts.map +1 -1
  53. package/dist/editor/hit-test.js +18 -10
  54. package/dist/editor/hit-test.js.map +1 -1
  55. package/dist/editor/interaction-state.d.ts +29 -2
  56. package/dist/editor/interaction-state.d.ts.map +1 -1
  57. package/dist/editor/interaction-state.js +34 -0
  58. package/dist/editor/interaction-state.js.map +1 -1
  59. package/dist/editor/link-handle-drag.d.ts +1 -1
  60. package/dist/editor/link-handle-drag.d.ts.map +1 -1
  61. package/dist/editor/link-handle-drag.js +1 -1
  62. package/dist/editor/link-handle-drag.js.map +1 -1
  63. package/dist/editor/pointer-binding.d.ts.map +1 -1
  64. package/dist/editor/pointer-binding.js +86 -24
  65. package/dist/editor/pointer-binding.js.map +1 -1
  66. package/dist/editor/public/arrange-group.d.ts +6 -5
  67. package/dist/editor/public/arrange-group.d.ts.map +1 -1
  68. package/dist/editor/public/arrange-group.js +39 -23
  69. package/dist/editor/public/arrange-group.js.map +1 -1
  70. package/dist/editor/public/clipboard.d.ts +1 -1
  71. package/dist/editor/public/clipboard.d.ts.map +1 -1
  72. package/dist/editor/public/clipboard.js +2 -2
  73. package/dist/editor/public/clipboard.js.map +1 -1
  74. package/dist/editor/public/cursor.js +1 -1
  75. package/dist/editor/public/cursor.js.map +1 -1
  76. package/dist/editor/public/eraser.js +1 -1
  77. package/dist/editor/public/eraser.js.map +1 -1
  78. package/dist/editor/public/image-insert.d.ts.map +1 -1
  79. package/dist/editor/public/image-insert.js +4 -1
  80. package/dist/editor/public/image-insert.js.map +1 -1
  81. package/dist/editor/public/label-seed.d.ts +12 -0
  82. package/dist/editor/public/label-seed.d.ts.map +1 -0
  83. package/dist/editor/public/label-seed.js +20 -0
  84. package/dist/editor/public/label-seed.js.map +1 -0
  85. package/dist/editor/public/layers.d.ts +1 -1
  86. package/dist/editor/public/layers.d.ts.map +1 -1
  87. package/dist/editor/public/placement.d.ts +6 -1
  88. package/dist/editor/public/placement.d.ts.map +1 -1
  89. package/dist/editor/public/placement.js +47 -17
  90. package/dist/editor/public/placement.js.map +1 -1
  91. package/dist/editor/public/selection-ops.d.ts +1 -1
  92. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  93. package/dist/editor/public/selection-ops.js +31 -2
  94. package/dist/editor/public/selection-ops.js.map +1 -1
  95. package/dist/editor/public/stroke-eraser-coverage.js +1 -1
  96. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -1
  97. package/dist/editor/public/stroke-eraser.js +1 -1
  98. package/dist/editor/public/stroke-eraser.js.map +1 -1
  99. package/dist/editor/public/text-edit.d.ts +4 -2
  100. package/dist/editor/public/text-edit.d.ts.map +1 -1
  101. package/dist/editor/public/text-edit.js +8 -4
  102. package/dist/editor/public/text-edit.js.map +1 -1
  103. package/dist/editor/public/tool-ops.d.ts +10 -7
  104. package/dist/editor/public/tool-ops.d.ts.map +1 -1
  105. package/dist/editor/public/tool-ops.js +141 -14
  106. package/dist/editor/public/tool-ops.js.map +1 -1
  107. package/dist/editor/public/zoom-pan.d.ts +10 -1
  108. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  109. package/dist/editor/public/zoom-pan.js +49 -0
  110. package/dist/editor/public/zoom-pan.js.map +1 -1
  111. package/dist/editor/render-orchestrator.d.ts +17 -11
  112. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  113. package/dist/editor/render-orchestrator.js +45 -31
  114. package/dist/editor/render-orchestrator.js.map +1 -1
  115. package/dist/editor/resize-helpers.d.ts +1 -1
  116. package/dist/editor/resize-helpers.d.ts.map +1 -1
  117. package/dist/editor/shape-traits.d.ts +1 -1
  118. package/dist/editor/shape-traits.d.ts.map +1 -1
  119. package/dist/editor/shape-traits.js +1 -1
  120. package/dist/editor/shape-traits.js.map +1 -1
  121. package/dist/editor/text-edit.d.ts +24 -11
  122. package/dist/editor/text-edit.d.ts.map +1 -1
  123. package/dist/editor/text-edit.js +225 -45
  124. package/dist/editor/text-edit.js.map +1 -1
  125. package/dist/editor.d.ts +306 -18
  126. package/dist/editor.d.ts.map +1 -1
  127. package/dist/editor.js +1233 -67
  128. package/dist/editor.js.map +1 -1
  129. package/dist/features/built-in-handlers.d.ts.map +1 -0
  130. package/dist/{built-in-handlers.js → features/built-in-handlers.js} +7 -1
  131. package/dist/features/built-in-handlers.js.map +1 -0
  132. package/dist/{clipboard.d.ts → features/clipboard.d.ts} +14 -0
  133. package/dist/features/clipboard.d.ts.map +1 -0
  134. package/dist/{clipboard.js → features/clipboard.js} +1 -1
  135. package/dist/features/clipboard.js.map +1 -0
  136. package/dist/{file-drop.d.ts → features/file-drop.d.ts} +20 -1
  137. package/dist/features/file-drop.d.ts.map +1 -0
  138. package/dist/{file-drop.js → features/file-drop.js} +47 -19
  139. package/dist/features/file-drop.js.map +1 -0
  140. package/dist/features/insert-scene.d.ts +19 -0
  141. package/dist/features/insert-scene.d.ts.map +1 -0
  142. package/dist/features/insert-scene.js +145 -0
  143. package/dist/features/insert-scene.js.map +1 -0
  144. package/dist/features/search.d.ts.map +1 -0
  145. package/dist/features/search.js.map +1 -0
  146. package/dist/helpers/auto-compact.d.ts.map +1 -0
  147. package/dist/{auto-compact.js → helpers/auto-compact.js} +1 -1
  148. package/dist/helpers/auto-compact.js.map +1 -0
  149. package/dist/helpers/auto-layout-scheduler.d.ts.map +1 -0
  150. package/dist/helpers/auto-layout-scheduler.js.map +1 -0
  151. package/dist/helpers/frame-helpers.d.ts.map +1 -0
  152. package/dist/helpers/frame-helpers.js.map +1 -0
  153. package/dist/helpers/group-helpers.d.ts.map +1 -0
  154. package/dist/helpers/group-helpers.js.map +1 -0
  155. package/dist/helpers/util.d.ts.map +1 -0
  156. package/dist/helpers/util.js.map +1 -0
  157. package/dist/index.d.ts +23 -21
  158. package/dist/index.d.ts.map +1 -1
  159. package/dist/index.js +12 -12
  160. package/dist/index.js.map +1 -1
  161. package/dist/input/dom-events.d.ts.map +1 -0
  162. package/dist/input/dom-events.js.map +1 -0
  163. package/dist/{hotkeys-binding.d.ts → input/hotkeys-binding.d.ts} +2 -2
  164. package/dist/input/hotkeys-binding.d.ts.map +1 -0
  165. package/dist/{hotkeys-binding.js → input/hotkeys-binding.js} +1 -1
  166. package/dist/input/hotkeys-binding.js.map +1 -0
  167. package/dist/input/platform.d.ts.map +1 -0
  168. package/dist/input/platform.js.map +1 -0
  169. package/dist/{handle.d.ts → interaction/handle.d.ts} +1 -1
  170. package/dist/interaction/handle.d.ts.map +1 -0
  171. package/dist/{handle.js → interaction/handle.js} +1 -1
  172. package/dist/interaction/handle.js.map +1 -0
  173. package/dist/interaction/interactive.d.ts.map +1 -0
  174. package/dist/interaction/interactive.js.map +1 -0
  175. package/dist/interaction/machine.d.ts.map +1 -0
  176. package/dist/interaction/machine.js.map +1 -0
  177. package/dist/interaction/modes.d.ts.map +1 -0
  178. package/dist/interaction/modes.js.map +1 -0
  179. package/dist/render/animation-tick.d.ts.map +1 -0
  180. package/dist/render/animation-tick.js.map +1 -0
  181. package/dist/{overlay.d.ts → render/overlay.d.ts} +42 -38
  182. package/dist/render/overlay.d.ts.map +1 -0
  183. package/dist/{overlay.js → render/overlay.js} +174 -59
  184. package/dist/render/overlay.js.map +1 -0
  185. package/dist/selection/link-selection.d.ts.map +1 -0
  186. package/dist/{link-selection.js → selection/link-selection.js} +1 -1
  187. package/dist/selection/link-selection.js.map +1 -0
  188. package/dist/selection/selection.d.ts.map +1 -0
  189. package/dist/{selection.js → selection/selection.js} +1 -1
  190. package/dist/selection/selection.js.map +1 -0
  191. package/package.json +5 -5
  192. package/dist/animation-tick.d.ts.map +0 -1
  193. package/dist/animation-tick.js.map +0 -1
  194. package/dist/auto-compact.d.ts.map +0 -1
  195. package/dist/auto-compact.js.map +0 -1
  196. package/dist/auto-layout-scheduler.d.ts.map +0 -1
  197. package/dist/auto-layout-scheduler.js.map +0 -1
  198. package/dist/built-in-handlers.d.ts.map +0 -1
  199. package/dist/built-in-handlers.js.map +0 -1
  200. package/dist/clipboard.d.ts.map +0 -1
  201. package/dist/clipboard.js.map +0 -1
  202. package/dist/dom-events.d.ts.map +0 -1
  203. package/dist/dom-events.js.map +0 -1
  204. package/dist/editor-events.d.ts.map +0 -1
  205. package/dist/file-drop.d.ts.map +0 -1
  206. package/dist/file-drop.js.map +0 -1
  207. package/dist/frame-helpers.d.ts.map +0 -1
  208. package/dist/frame-helpers.js.map +0 -1
  209. package/dist/group-helpers.d.ts.map +0 -1
  210. package/dist/group-helpers.js.map +0 -1
  211. package/dist/handle.d.ts.map +0 -1
  212. package/dist/handle.js.map +0 -1
  213. package/dist/hotkeys-binding.d.ts.map +0 -1
  214. package/dist/hotkeys-binding.js.map +0 -1
  215. package/dist/interactive.d.ts.map +0 -1
  216. package/dist/interactive.js.map +0 -1
  217. package/dist/link-selection.d.ts.map +0 -1
  218. package/dist/link-selection.js.map +0 -1
  219. package/dist/machine.d.ts.map +0 -1
  220. package/dist/machine.js.map +0 -1
  221. package/dist/modes.d.ts.map +0 -1
  222. package/dist/modes.js.map +0 -1
  223. package/dist/overlay.d.ts.map +0 -1
  224. package/dist/overlay.js.map +0 -1
  225. package/dist/platform.d.ts.map +0 -1
  226. package/dist/platform.js.map +0 -1
  227. package/dist/search.d.ts.map +0 -1
  228. package/dist/search.js.map +0 -1
  229. package/dist/selection.d.ts.map +0 -1
  230. package/dist/selection.js.map +0 -1
  231. package/dist/util.d.ts.map +0 -1
  232. package/dist/util.js.map +0 -1
  233. /package/dist/{editor-events.js → editor/editor-events.js} +0 -0
  234. /package/dist/{built-in-handlers.d.ts → features/built-in-handlers.d.ts} +0 -0
  235. /package/dist/{search.d.ts → features/search.d.ts} +0 -0
  236. /package/dist/{search.js → features/search.js} +0 -0
  237. /package/dist/{auto-compact.d.ts → helpers/auto-compact.d.ts} +0 -0
  238. /package/dist/{auto-layout-scheduler.d.ts → helpers/auto-layout-scheduler.d.ts} +0 -0
  239. /package/dist/{auto-layout-scheduler.js → helpers/auto-layout-scheduler.js} +0 -0
  240. /package/dist/{frame-helpers.d.ts → helpers/frame-helpers.d.ts} +0 -0
  241. /package/dist/{frame-helpers.js → helpers/frame-helpers.js} +0 -0
  242. /package/dist/{group-helpers.d.ts → helpers/group-helpers.d.ts} +0 -0
  243. /package/dist/{group-helpers.js → helpers/group-helpers.js} +0 -0
  244. /package/dist/{util.d.ts → helpers/util.d.ts} +0 -0
  245. /package/dist/{util.js → helpers/util.js} +0 -0
  246. /package/dist/{dom-events.d.ts → input/dom-events.d.ts} +0 -0
  247. /package/dist/{dom-events.js → input/dom-events.js} +0 -0
  248. /package/dist/{platform.d.ts → input/platform.d.ts} +0 -0
  249. /package/dist/{platform.js → input/platform.js} +0 -0
  250. /package/dist/{interactive.d.ts → interaction/interactive.d.ts} +0 -0
  251. /package/dist/{interactive.js → interaction/interactive.js} +0 -0
  252. /package/dist/{machine.d.ts → interaction/machine.d.ts} +0 -0
  253. /package/dist/{machine.js → interaction/machine.js} +0 -0
  254. /package/dist/{modes.d.ts → interaction/modes.d.ts} +0 -0
  255. /package/dist/{modes.js → interaction/modes.js} +0 -0
  256. /package/dist/{animation-tick.d.ts → render/animation-tick.d.ts} +0 -0
  257. /package/dist/{animation-tick.js → render/animation-tick.js} +0 -0
  258. /package/dist/{link-selection.d.ts → selection/link-selection.d.ts} +0 -0
  259. /package/dist/{selection.d.ts → selection/selection.d.ts} +0 -0
package/dist/editor.js CHANGED
@@ -1,23 +1,24 @@
1
1
  import { createActor } from "xstate";
2
2
  import { createEmitter } from "@oh-just-another/events";
3
3
  import { elementId as castElementId } from "@oh-just-another/types";
4
- import { addElement, getLinkCurvePoints, linkLabelAnchor, addLink, endpointElementId, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementLocalBounds, localToWorld, worldToLocal, isFrame, isGroup, isText, isImage, isBrush, brushBodyColor, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, SnapEngine, invert, updateLink, updateElement, isAnchorRef, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
5
- import { layerId as castLayerId, } from "@oh-just-another/types";
6
- import { bounds as B, matrix, vec2 } from "@oh-just-another/math";
4
+ import { addElement, getLinkCurvePoints, linkLabelAnchor, addLink, endpointElementId, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementLocalBounds, localToWorld, worldToLocal, isFrame, isGroup, isText, isImage, isBrush, brushBodyColor, canCarryLabel, isSticky, isEmoji, getBinaryFile, paragraphAt, paragraphCount, paragraphRangeForOffsets, normalizeParagraphs, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, SnapEngine, invert, updateLink, updateElement, isAnchorRef, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
5
+ import { fileId as castFileId, layerId as castLayerId, linkId as castLinkId, } from "@oh-just-another/types";
6
+ import { bounds as B, matrix } from "@oh-just-another/math";
7
7
  import { onAnimationContentReady, setActiveRasterizer, setActiveTextShaper, ElementCache, } from "@oh-just-another/renderer-core";
8
8
  import { History, } from "@oh-just-another/history";
9
9
  import { DEFAULT_LINK_ROUTING } from "./constants.js";
10
- import { FileDropRegistry } from "./file-drop.js";
11
- import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
12
- import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
13
- import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
14
- import { AutoCompactScheduler } from "./auto-compact.js";
15
- import { AutoLayoutScheduler } from "./auto-layout-scheduler.js";
16
- import { DEFAULT_SNAP_THRESHOLD, LINK_ENDPOINT_HANDLE_RADIUS, LINK_HIT_THRESHOLD, LARGE_SCENE_HIT_THRESHOLD, TOUCH_LINK_HANDLE_HIT_SLOP, TOUCH_LINK_HIT_THRESHOLD, TOUCH_HANDLE_HIT_SLOP, ANCHOR_START_HIT_SLOP, ANCHOR_DOT_CLICK_RADIUS, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, DOUBLE_CLICK_MS, DOUBLE_CLICK_TOLERANCE_PX, WHEEL_ZOOM_STEP, ROTATE_SNAP_RADIANS, CROP_HANDLE_HIT_RADIUS, FLOWCHART_MAX_SIBLINGS, ERASER_TRAIL_TTL_MS, } from "./constants.js";
17
- import { HANDLE_HIT_SLOP } from "./handle.js";
18
- import { req } from "./util.js";
19
- import { interactionMachine, } from "./machine.js";
20
- import { DEFAULT_MODE } from "./modes.js";
10
+ import { FileDropRegistry, } from "./features/file-drop.js";
11
+ import { imageFileDropHandler, videoFileDropHandler, createHiddenLoopingVideo, } from "./features/built-in-handlers.js";
12
+ import { insertSceneFragment } from "./features/insert-scene.js";
13
+ import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./helpers/group-helpers.js";
14
+ import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./helpers/frame-helpers.js";
15
+ import { AutoCompactScheduler } from "./helpers/auto-compact.js";
16
+ import { AutoLayoutScheduler } from "./helpers/auto-layout-scheduler.js";
17
+ import { DEFAULT_SNAP_THRESHOLD, LINK_ENDPOINT_HANDLE_RADIUS, LINK_HIT_THRESHOLD, LARGE_SCENE_HIT_THRESHOLD, TOUCH_LINK_HANDLE_HIT_SLOP, TOUCH_LINK_HIT_THRESHOLD, TOUCH_HANDLE_HIT_SLOP, ANCHOR_START_HIT_SLOP, ANCHOR_DOT_CLICK_RADIUS, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, WHEEL_ZOOM_STEP, ROTATE_SNAP_RADIANS, CROP_HANDLE_HIT_RADIUS, FLOWCHART_MAX_SIBLINGS, ERASER_TRAIL_TTL_MS, MAX_LIST_INDENT, IMAGE_ASPECT_PRESETS, LINK_DRAW_PRESETS, OBJECT_SNAP_MAX_CANDIDATES, OBJECT_SNAP_MIN_SIZE_PX, OBJECT_SNAP_THRESHOLD_PX, SIZE_SUGGEST_THRESHOLD_PX, DEFAULT_EDITOR_PREFERENCES, } from "./constants.js";
18
+ import { HANDLE_HIT_SLOP } from "./interaction/handle.js";
19
+ import { req } from "./helpers/util.js";
20
+ import { interactionMachine, } from "./interaction/machine.js";
21
+ import { DEFAULT_MODE } from "./interaction/modes.js";
21
22
  import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
22
23
  import { AnimationController } from "./editor/animation.js";
23
24
  import { GestureController } from "./editor/gesture-tx.js";
@@ -38,7 +39,7 @@ import { coveredLength } from "./editor/public/stroke-eraser-coverage.js";
38
39
  import { beginLaserStroke as beginLaserStrokePure, extendLaserStroke as extendLaserStrokePure, pruneLaserStrokes, } from "./editor/public/laser.js";
39
40
  import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
40
41
  import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, computeRenameLayer, computeToggleLayerLock, computeToggleLayerVisibility, newLayerId, } from "./editor/public/layers.js";
41
- import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, } from "./editor/public/zoom-pan.js";
42
+ import { computePan, clampZoom, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, computeRevealNearest, } from "./editor/public/zoom-pan.js";
42
43
  import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
43
44
  import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
44
45
  import { computeCursor } from "./editor/public/cursor.js";
@@ -47,7 +48,8 @@ import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, comp
47
48
  import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
48
49
  import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeApplyTextRunStyle, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
49
50
  import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
50
- import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
51
+ import { beginPlacementState, buildElementAtCursor, buildStickyElementAt, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
52
+ import { withLabel } from "./editor/public/label-seed.js";
51
53
  import { computeConvertType, computeCommitImageCrop, computeCropBodyPan, computeCropHandleDrag, computeSpawnConnectedNode, computeSpawnConnectedNodes, cropFullImageLocalRect, cropHandleWorldPoints, CROP_HANDLES, FULL_CROP, pickColorAt, } from "./editor/public/tool-ops.js";
52
54
  import { renderEditor, } from "./editor/render-orchestrator.js";
53
55
  import { TextEditController } from "./editor/text-edit.js";
@@ -61,9 +63,10 @@ import { computeAlignPatches, computeDistributePatches, computeFlipPatches, comp
61
63
  import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
62
64
  import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
63
65
  import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
64
- import {} from "./overlay.js";
65
- import * as Selection from "./selection.js";
66
- import * as LinkSelection from "./link-selection.js";
66
+ import { snapMoveDeltaToObjects, snapResizeDeltaToObjects, } from "./editor/applies/object-snap.js";
67
+ import {} from "./render/overlay.js";
68
+ import * as Selection from "./selection/selection.js";
69
+ import * as LinkSelection from "./selection/link-selection.js";
67
70
  /**
68
71
  * Machine-emit types dropped while the editor is in read-only mode — every
69
72
  * scene mutation the interaction machine can produce. Selection / lasso /
@@ -86,6 +89,24 @@ const READ_ONLY_BLOCKED_EMITS = new Set([
86
89
  const EMPTY_ELEMENT_SET = Object.freeze(new Set());
87
90
  /** Monotonic wall-clock in ms, matching the domain used by the overlay fade. */
88
91
  const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
92
+ /** `rotation` is a multiple of 90° (within float noise) — the only poses that snap. */
93
+ const isRightAngle = (rotation) => {
94
+ const q = rotation / (Math.PI / 2);
95
+ return Math.abs(q - Math.round(q)) < 1e-6;
96
+ };
97
+ /**
98
+ * Camera at the scene's saved start view (pan + zoom), or the scene itself
99
+ * when no start view is set or the camera is already there.
100
+ */
101
+ const withStartViewApplied = (scene) => {
102
+ const sv = scene.viewport.startView;
103
+ if (sv === undefined)
104
+ return scene;
105
+ const { pan, zoom } = scene.viewport;
106
+ if (pan.x === sv.pan.x && pan.y === sv.pan.y && zoom === sv.zoom)
107
+ return scene;
108
+ return { ...scene, viewport: { ...scene.viewport, pan: { ...sv.pan }, zoom: sv.zoom } };
109
+ };
89
110
  export class Editor {
90
111
  host;
91
112
  mainTarget;
@@ -397,6 +418,14 @@ export class Editor {
397
418
  * this lets that active→inactive transition force one full repaint.
398
419
  */
399
420
  lastRenderedEraseActive = false;
421
+ /**
422
+ * Last-painted hovered sticky — paired with `lastRenderedScene` like
423
+ * {@link lastRenderedEnteredGroup}. Hovering changes only chrome (the
424
+ * "+" add-reaction button), not the scene, so the dirty-rect diff is
425
+ * empty and the button would never appear / disappear without this
426
+ * forcing a full repaint on hover transitions.
427
+ */
428
+ lastRenderedHoveredSticky = null;
400
429
  /**
401
430
  * Set whenever an eraser move actually CHANGES the marked / cut set (a new
402
431
  * shape marked, un-marked, or a brush point cut). Gates the forced full
@@ -593,6 +622,13 @@ export class Editor {
593
622
  set panGesture(v) {
594
623
  this.interaction.panGesture = v;
595
624
  }
625
+ /** Element being placed by a library drag, or null (see `beginPlacement`). */
626
+ get placementId() {
627
+ return this.interaction.placementId;
628
+ }
629
+ set placementId(v) {
630
+ this.interaction.placementId = v;
631
+ }
596
632
  /**
597
633
  * Set on a right-click pointerdown so the upcoming native
598
634
  * `contextmenu` event can be unconditionally preventDefault'ed
@@ -670,7 +706,8 @@ export class Editor {
670
706
  this.overlayTarget = options.overlayTarget;
671
707
  this.backgroundTarget = options.backgroundTarget ?? null;
672
708
  this.onAfterRender = options.onAfterRender ?? null;
673
- this._scene = options.initialScene;
709
+ this._preferences = { ...DEFAULT_EDITOR_PREFERENCES, ...options.preferences };
710
+ this._scene = withStartViewApplied(options.initialScene);
674
711
  this._history = isHistoryProvider(options.history)
675
712
  ? options.history
676
713
  : new History(options.history ?? {});
@@ -842,8 +879,7 @@ export class Editor {
842
879
  // Long-press controller — fired on touch-hold; fans out to
843
880
  // host-registered listeners (mobile alt to right-click).
844
881
  this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
845
- for (const fn of this.longPressListeners)
846
- fn(payload);
882
+ this.fireContextPress(payload);
847
883
  });
848
884
  // Pinch gesture controller — two-finger pan + zoom. Hooks into
849
885
  // the editor's own zoomAt / panBy / screenToWorld.
@@ -1230,6 +1266,88 @@ export class Editor {
1230
1266
  toggleGrid() {
1231
1267
  this.setGrid({ enabled: !this.gridEnabled });
1232
1268
  }
1269
+ _preferences;
1270
+ /** Per-user preferences — see {@link EditorPreferences}. */
1271
+ get preferences() {
1272
+ return this._preferences;
1273
+ }
1274
+ /**
1275
+ * Merge `patch` over the current preferences. Not part of the document
1276
+ * and never recorded in history; hosts persist the result per browser.
1277
+ * Subscribers are notified (the object identity changes only when a
1278
+ * value actually changed).
1279
+ */
1280
+ setPreferences(patch) {
1281
+ const next = { ...this._preferences, ...patch };
1282
+ if (next.snapObjects === this._preferences.snapObjects &&
1283
+ next.showObjectSize === this._preferences.showObjectSize &&
1284
+ next.suggestObjectSize === this._preferences.suggestObjectSize &&
1285
+ next.wheelMode === this._preferences.wheelMode) {
1286
+ return;
1287
+ }
1288
+ this._preferences = next;
1289
+ this.notify();
1290
+ }
1291
+ /**
1292
+ * Clear the `locked` flag on every locked element (one undo step).
1293
+ * The canvas menu's "Unlock all"; no-op when nothing is locked.
1294
+ */
1295
+ unlockAll() {
1296
+ if (this.readOnly)
1297
+ return;
1298
+ const ids = [...this._scene.elements.values()]
1299
+ .filter((s) => s.locked === true)
1300
+ .map((s) => s.id);
1301
+ if (ids.length === 0)
1302
+ return;
1303
+ const tx = this._history.transaction();
1304
+ for (const id of ids) {
1305
+ const r = updateElement(this._scene, id, (s) => {
1306
+ const copy = { ...s };
1307
+ delete copy.locked;
1308
+ return copy;
1309
+ });
1310
+ this._scene = r.scene;
1311
+ tx.add(r.patch);
1312
+ }
1313
+ tx.commit();
1314
+ this.notify();
1315
+ this.announce(`Unlocked ${String(ids.length)} element(s)`);
1316
+ }
1317
+ /** The document's saved start view, or `null` when none is set. */
1318
+ get startView() {
1319
+ return this._scene.viewport.startView ?? null;
1320
+ }
1321
+ /**
1322
+ * Save the current camera (pan + zoom) as the document's start view —
1323
+ * where the document opens and where {@link goToStartView} jumps.
1324
+ * Persists in the viewport (exported with the scene), not in history.
1325
+ */
1326
+ setCurrentViewAsStart() {
1327
+ const { pan, zoom } = this._scene.viewport;
1328
+ this._scene = {
1329
+ ...this._scene,
1330
+ viewport: { ...this._scene.viewport, startView: { pan: { ...pan }, zoom } },
1331
+ };
1332
+ this.notify();
1333
+ }
1334
+ /** Forget the saved start view. No-op when none is set. */
1335
+ clearStartView() {
1336
+ if (this._scene.viewport.startView === undefined)
1337
+ return;
1338
+ const { startView: _dropped, ...viewport } = this._scene.viewport;
1339
+ void _dropped;
1340
+ this._scene = { ...this._scene, viewport };
1341
+ this.notify();
1342
+ }
1343
+ /** Move the camera to the saved start view. No-op when none is set. */
1344
+ goToStartView() {
1345
+ const next = withStartViewApplied(this._scene);
1346
+ if (next === this._scene)
1347
+ return;
1348
+ this._scene = next;
1349
+ this.notify();
1350
+ }
1233
1351
  /** All currently-selected link (connector) ids. */
1234
1352
  get selectedLinks() {
1235
1353
  return this._selectedLinks;
@@ -1275,6 +1393,10 @@ export class Editor {
1275
1393
  unregisterFileDropHandler(id) {
1276
1394
  this.fileDropRegistry.unregister(id);
1277
1395
  }
1396
+ /** Registered file-drop handlers, in dispatch order — the drop overlay lists the labelled ones. */
1397
+ getFileDropHandlers() {
1398
+ return this.fileDropRegistry.getAll();
1399
+ }
1278
1400
  /**
1279
1401
  * Dispatch a dropped file (or pasted file from clipboard) into
1280
1402
  * the registered handlers. Returns `true` when a handler accepted
@@ -1307,10 +1429,46 @@ export class Editor {
1307
1429
  * toolbar buttons and hotkeys reach it through the action registry.
1308
1430
  * Records the outgoing tool in `activeTool.lastActiveTool`.
1309
1431
  */
1432
+ /**
1433
+ * Shape kind the next rubber-band draw creates while `draw-rect` is
1434
+ * armed (the "Shapes and lines" flyout arms diamond / triangle through
1435
+ * the same mode). Reset to "rect" on every tool switch so hotkeys and
1436
+ * plain toolbar buttons keep their stock behaviour.
1437
+ */
1438
+ _drawShapeKind = "rect";
1439
+ get drawShapeKind() {
1440
+ return this._drawShapeKind;
1441
+ }
1442
+ /**
1443
+ * Connector preset for NEW links drawn in `draw-edge` mode (see
1444
+ * `LINK_DRAW_PRESETS`); `null` = stock defaults. Reset on tool switch.
1445
+ */
1446
+ _linkDrawPreset = null;
1447
+ get linkDrawPreset() {
1448
+ return this._linkDrawPreset;
1449
+ }
1450
+ /** Arm the shape-drawing tool for `kind` (the flyout's shape rows). */
1451
+ armShapeTool(kind) {
1452
+ this.setActiveTool(kind === "ellipse" ? "draw-ellipse" : "draw-rect");
1453
+ this._drawShapeKind = kind;
1454
+ // `setActiveTool` already notified with the reset variant — notify again
1455
+ // so subscribers (the flyout's active row) see the armed kind.
1456
+ this.notify();
1457
+ }
1458
+ /** Arm the connector tool with a line preset (the flyout's line rows). */
1459
+ armLineTool(preset) {
1460
+ this.setActiveTool("draw-edge");
1461
+ this._linkDrawPreset = preset;
1462
+ this.notify();
1463
+ }
1310
1464
  setActiveTool(mode) {
1311
1465
  const prev = this.actor.getSnapshot().context.mode;
1312
1466
  if (prev !== mode)
1313
1467
  this._lastActiveTool = prev;
1468
+ // Flyout-armed variants reset on every switch — `armShapeTool` /
1469
+ // `armLineTool` re-set them right after when they are the caller.
1470
+ this._drawShapeKind = "rect";
1471
+ this._linkDrawPreset = null;
1314
1472
  // A tool switch cancels any armed colour-picker pipette.
1315
1473
  this.pendingEyedropperPick = null;
1316
1474
  // Switching tools commits any in-flight text edit (standard: leaving the
@@ -1326,6 +1484,18 @@ export class Editor {
1326
1484
  if (mode !== "draw-edge" && this.hoveredLinkTarget !== null) {
1327
1485
  this.hoveredLinkTarget = null;
1328
1486
  }
1487
+ // Creation / ink / laser tools work on a blank slate: the selection (and
1488
+ // with it the selection toolbar) goes away. `select` and `hand` keep it —
1489
+ // panning is not editing — and `crop` is entered WITH the image selected.
1490
+ if (mode !== "select" && mode !== "hand" && mode !== "crop") {
1491
+ this._selection = Selection.EMPTY;
1492
+ this._selectedLinks = LinkSelection.EMPTY;
1493
+ }
1494
+ // Leaving crop for any other tool abandons the pending crop box (the
1495
+ // commit / cancel paths route through here with the session already
1496
+ // cleared), so no crop frame lingers over the image.
1497
+ if (prev === "crop" && mode !== "crop")
1498
+ this.cropSession = null;
1329
1499
  this.actor.send({ type: "SET_MODE", mode });
1330
1500
  // Cursor affordance follows the new mode (hand → grab, draw tools →
1331
1501
  // crosshair, etc.) — recompute through the single chokepoint.
@@ -1459,6 +1629,21 @@ export class Editor {
1459
1629
  this.scheduleRender();
1460
1630
  }
1461
1631
  }
1632
+ /**
1633
+ * Sticky under the idle cursor (its render bounds, so the reaction row
1634
+ * below the card counts too) — drives the canvas-drawn hover-only "+"
1635
+ * add-reaction button. Set by the pointer-move path; `null` clears.
1636
+ */
1637
+ _hoveredStickyId = null;
1638
+ get hoveredStickyId() {
1639
+ return this._hoveredStickyId;
1640
+ }
1641
+ setHoveredSticky(id) {
1642
+ if (this._hoveredStickyId === id)
1643
+ return;
1644
+ this._hoveredStickyId = id;
1645
+ this.notify();
1646
+ }
1462
1647
  /** True when the shape's GIF is paused (drives the overlay badge). */
1463
1648
  isPlaybackPaused(id) {
1464
1649
  return this.gifPlayback.isPaused(id);
@@ -1502,6 +1687,7 @@ export class Editor {
1502
1687
  const initial = add(this._scene);
1503
1688
  this._scene = initial.scene;
1504
1689
  this._selection = Selection.single(shape.id);
1690
+ this.placementId = shape.id;
1505
1691
  this.notify();
1506
1692
  const state = { ...initialState };
1507
1693
  return {
@@ -1519,6 +1705,7 @@ export class Editor {
1519
1705
  }
1520
1706
  tx.add({ kind: "element", id: shape.id, before: null, after: state.current });
1521
1707
  tx.commit();
1708
+ this.placementId = null;
1522
1709
  // Notify is mandatory here. The dragover snapshots carried the
1523
1710
  // placement preview WITHOUT `parentId`, so the
1524
1711
  // AutoLayoutScheduler's `signatureFor(parent)` did not include the
@@ -1532,6 +1719,7 @@ export class Editor {
1532
1719
  const { scene } = computePlacementCancel(this._scene, shape.id);
1533
1720
  this._scene = scene;
1534
1721
  tx.cancel();
1722
+ this.placementId = null;
1535
1723
  this._selection = Selection.EMPTY;
1536
1724
  this.notify();
1537
1725
  },
@@ -1849,6 +2037,25 @@ export class Editor {
1849
2037
  this.beginTextEdit(id);
1850
2038
  return id;
1851
2039
  }
2040
+ /**
2041
+ * Drop a default sticky note centred on `worldPoint`, select it and open
2042
+ * its inline editor (the canvas menu's "Add sticky note"). One undo step.
2043
+ */
2044
+ createStickyAt(worldPoint) {
2045
+ if (this.readOnly)
2046
+ return null;
2047
+ const id = newElementIdAtCursor(++this.nextId);
2048
+ const shape = buildStickyElementAt(this._scene, worldPoint, this._activeLayerId, id);
2049
+ const r = addElement(this._scene, shape);
2050
+ this._scene = r.scene;
2051
+ this._history.push(r.patch);
2052
+ this._selection = Selection.single(id);
2053
+ this._selectedLinks = LinkSelection.EMPTY;
2054
+ this.notify();
2055
+ this.announce(`Created sticky ${id}`);
2056
+ this.beginTextEdit(id);
2057
+ return id;
2058
+ }
1852
2059
  /** Current brush paint settings (line colour, fill, opacity, width). */
1853
2060
  _brushSettings = DEFAULT_BRUSH_SETTINGS;
1854
2061
  get brushSettings() {
@@ -2377,6 +2584,31 @@ export class Editor {
2377
2584
  this.notify();
2378
2585
  this.announce(`Pasted ${result.newIds.length} shapes`);
2379
2586
  }
2587
+ /**
2588
+ * Insert a whole scene fragment (an imported diagram — elements, links,
2589
+ * binary files) into the current scene, centred on `targetWorld` (default:
2590
+ * the last pointer position, else the fragment stays where it is). Ids are
2591
+ * remapped, everything lands on the active layer, one undo step; the new
2592
+ * elements become the selection. Returns their ids.
2593
+ */
2594
+ insertScene(fragment, targetWorld) {
2595
+ if (this.readOnly)
2596
+ return [];
2597
+ this.finalizeOpenGestureTx();
2598
+ const target = targetWorld ?? this.lastPointerWorld;
2599
+ const result = insertSceneFragment(this._scene, this._history, fragment, target ?? null, this._activeLayerId, () => castElementId(`shape-${String(++this.nextId)}-${Date.now().toString(36)}`), () => castLinkId(`link-${String(++this.nextId)}-${Date.now().toString(36)}`), () => castFileId(`file-${String(++this.nextId)}-${Date.now().toString(36)}`));
2600
+ this._scene = result.scene;
2601
+ this._selection = selectionFromPasted(result.newIds);
2602
+ // Same as `loadScene`: rebuild live handles for the inserted media from
2603
+ // `Scene.files` (importers hand images over as data URLs — persisted as
2604
+ // files above — and a native fragment carries its own files).
2605
+ animScene.rehydrateAnimatedImages(this);
2606
+ void animScene.rehydrateStaticImages(this);
2607
+ this.notify();
2608
+ this.maybeAnimate();
2609
+ this.announce(`Inserted ${String(result.newIds.length)} shapes`);
2610
+ return result.newIds;
2611
+ }
2380
2612
  /**
2381
2613
  * Merge `partial` into the `style` of every shape in `ids`. Useful
2382
2614
  * for the inspector / PropertyPanel: flipping `roundness`, swapping
@@ -2453,6 +2685,610 @@ export class Editor {
2453
2685
  this._history.push(result.patch);
2454
2686
  this.notify();
2455
2687
  }
2688
+ /**
2689
+ * Merge a partial text style into the embedded label's style on every
2690
+ * shape in `ids` that carries a label. One undo step. The counterpart
2691
+ * of `updateStyle` for shape labels (which must not collide with the
2692
+ * shape body's own fill / stroke).
2693
+ */
2694
+ updateLabelStyle(ids, partial) {
2695
+ if (this.readOnly)
2696
+ return;
2697
+ const tx = this._history.transaction();
2698
+ let changed = false;
2699
+ for (const id of ids) {
2700
+ const shape = getElement(this._scene, id);
2701
+ // A labelable shape without a label yet gets one so the style sticks
2702
+ // (create-on-write); non-labelable shapes are skipped.
2703
+ if (shape === undefined || withLabel(shape).label === undefined)
2704
+ continue;
2705
+ const r = updateElement(this._scene, id, (raw) => {
2706
+ const s = withLabel(raw);
2707
+ return s.label === undefined
2708
+ ? s
2709
+ : { ...s, label: { ...s.label, style: { ...s.label.style, ...partial } } };
2710
+ });
2711
+ this._scene = r.scene;
2712
+ tx.add(r.patch);
2713
+ changed = true;
2714
+ }
2715
+ if (!changed)
2716
+ return;
2717
+ tx.commit();
2718
+ this.notify();
2719
+ }
2720
+ /**
2721
+ * Merge font family / size into the embedded label. Picking an
2722
+ * explicit `fontSize` also leaves auto-fit mode (reference behaviour —
2723
+ * a concrete size wins over the automatic one). One undo step.
2724
+ */
2725
+ updateLabelProps(ids, partial) {
2726
+ if (this.readOnly)
2727
+ return;
2728
+ const tx = this._history.transaction();
2729
+ let changed = false;
2730
+ for (const id of ids) {
2731
+ const shape = getElement(this._scene, id);
2732
+ if (shape === undefined || withLabel(shape).label === undefined)
2733
+ continue;
2734
+ const r = updateElement(this._scene, id, (raw) => {
2735
+ const s = withLabel(raw);
2736
+ if (s.label === undefined)
2737
+ return s;
2738
+ const label = { ...s.label, ...partial };
2739
+ if (partial.fontSize !== undefined)
2740
+ delete label.autoFit;
2741
+ return { ...s, label };
2742
+ });
2743
+ this._scene = r.scene;
2744
+ tx.add(r.patch);
2745
+ changed = true;
2746
+ }
2747
+ if (!changed)
2748
+ return;
2749
+ tx.commit();
2750
+ this.notify();
2751
+ }
2752
+ /** Toggle auto-fit label sizing (sticky notes). One undo step. */
2753
+ setLabelAutoFit(ids, on) {
2754
+ if (this.readOnly)
2755
+ return;
2756
+ const tx = this._history.transaction();
2757
+ let changed = false;
2758
+ for (const id of ids) {
2759
+ const shape = getElement(this._scene, id);
2760
+ if (shape === undefined || withLabel(shape).label === undefined)
2761
+ continue;
2762
+ const r = updateElement(this._scene, id, (raw) => {
2763
+ const s = withLabel(raw);
2764
+ if (s.label === undefined)
2765
+ return s;
2766
+ const label = { ...s.label };
2767
+ if (on)
2768
+ label.autoFit = true;
2769
+ else
2770
+ delete label.autoFit;
2771
+ return { ...s, label };
2772
+ });
2773
+ this._scene = r.scene;
2774
+ tx.add(r.patch);
2775
+ changed = true;
2776
+ }
2777
+ if (!changed)
2778
+ return;
2779
+ tx.commit();
2780
+ this.notify();
2781
+ }
2782
+ /**
2783
+ * Resize sticky notes to a square preset side (`STICKY_SIZE_PRESETS`).
2784
+ * One undo step; non-sticky ids are skipped.
2785
+ */
2786
+ setStickySize(ids, side) {
2787
+ if (this.readOnly)
2788
+ return;
2789
+ const tx = this._history.transaction();
2790
+ let changed = false;
2791
+ for (const id of ids) {
2792
+ const shape = getElement(this._scene, id);
2793
+ if (!shape || !isSticky(shape))
2794
+ continue;
2795
+ const r = updateElement(this._scene, id, (s) => ({ ...s, width: side, height: side }));
2796
+ this._scene = r.scene;
2797
+ tx.add(r.patch);
2798
+ changed = true;
2799
+ }
2800
+ if (!changed)
2801
+ return;
2802
+ tx.commit();
2803
+ this.notify();
2804
+ }
2805
+ /**
2806
+ * Toggle the author-name strip on sticky notes. Turning it on fills a
2807
+ * missing `authorName` from the editor's comment author (the local
2808
+ * user). One undo step.
2809
+ */
2810
+ toggleStickyAuthor(ids) {
2811
+ if (this.readOnly)
2812
+ return;
2813
+ const tx = this._history.transaction();
2814
+ let changed = false;
2815
+ for (const id of ids) {
2816
+ const shape = getElement(this._scene, id);
2817
+ if (!shape || !isSticky(shape))
2818
+ continue;
2819
+ const r = updateElement(this._scene, id, (s) => {
2820
+ const sticky = s;
2821
+ const showAuthor = sticky.showAuthor !== true;
2822
+ return {
2823
+ ...s,
2824
+ showAuthor,
2825
+ authorName: sticky.authorName ?? this.commentAuthor.name,
2826
+ };
2827
+ });
2828
+ this._scene = r.scene;
2829
+ tx.add(r.patch);
2830
+ changed = true;
2831
+ }
2832
+ if (!changed)
2833
+ return;
2834
+ tx.commit();
2835
+ this.notify();
2836
+ }
2837
+ /** Replace the tag list on sticky notes. One undo step. */
2838
+ setStickyTags(ids, tags) {
2839
+ if (this.readOnly)
2840
+ return;
2841
+ const tx = this._history.transaction();
2842
+ let changed = false;
2843
+ for (const id of ids) {
2844
+ const shape = getElement(this._scene, id);
2845
+ if (!shape || !isSticky(shape))
2846
+ continue;
2847
+ const r = updateElement(this._scene, id, (s) => {
2848
+ const copy = { ...s };
2849
+ if (tags.length === 0)
2850
+ delete copy.tags;
2851
+ else
2852
+ copy.tags = tags;
2853
+ return copy;
2854
+ });
2855
+ this._scene = r.scene;
2856
+ tx.add(r.patch);
2857
+ changed = true;
2858
+ }
2859
+ if (!changed)
2860
+ return;
2861
+ tx.commit();
2862
+ this.notify();
2863
+ }
2864
+ /**
2865
+ * Toggle the local user's emoji reaction on a sticky note. A glyph the
2866
+ * user has NOT reacted with gains their reaction (+1); one they
2867
+ * already reacted with loses it (-1, removing the glyph at zero).
2868
+ * Other users' reactions are never touched — so counters only grow
2869
+ * through OTHER collaborators. Plain element data → syncs through the
2870
+ * normal scene channel. One undo step.
2871
+ */
2872
+ toggleStickyReaction(id, glyph) {
2873
+ if (this.readOnly)
2874
+ return;
2875
+ const shape = getElement(this._scene, id);
2876
+ if (!shape || !isSticky(shape))
2877
+ return;
2878
+ const user = this.commentAuthor.id;
2879
+ const r = updateElement(this._scene, id, (s) => {
2880
+ const sticky = s;
2881
+ const reactions = [...(sticky.reactions ?? [])];
2882
+ const i = reactions.findIndex((x) => x.glyph === glyph);
2883
+ const existing = i >= 0 ? reactions[i] : undefined;
2884
+ // Pre-per-user scenes may carry `{glyph, count}` without `users` —
2885
+ // treat those as nobody-having-reacted and rewrite on first toggle.
2886
+ const existingUsers = existing?.users ?? [];
2887
+ if (existing === undefined) {
2888
+ reactions.push({ glyph, users: [user] });
2889
+ }
2890
+ else if (existingUsers.includes(user)) {
2891
+ const users = existingUsers.filter((u) => u !== user);
2892
+ if (users.length === 0)
2893
+ reactions.splice(i, 1);
2894
+ else
2895
+ reactions[i] = { glyph, users };
2896
+ }
2897
+ else {
2898
+ reactions[i] = { glyph, users: [...existingUsers, user] };
2899
+ }
2900
+ const copy = { ...s };
2901
+ if (reactions.length === 0)
2902
+ delete copy.reactions;
2903
+ else
2904
+ copy.reactions = reactions;
2905
+ return copy;
2906
+ });
2907
+ this._scene = r.scene;
2908
+ this._history.push(r.patch);
2909
+ this.notify();
2910
+ }
2911
+ /** Replace an emoji element's glyph (the toolbar picker). One undo step. */
2912
+ setEmojiGlyph(ids, glyph) {
2913
+ if (this.readOnly)
2914
+ return;
2915
+ const tx = this._history.transaction();
2916
+ let changed = false;
2917
+ for (const id of ids) {
2918
+ const shape = getElement(this._scene, id);
2919
+ if (!shape || !isEmoji(shape))
2920
+ continue;
2921
+ const r = updateElement(this._scene, id, (s) => ({ ...s, glyph }));
2922
+ this._scene = r.scene;
2923
+ tx.add(r.patch);
2924
+ changed = true;
2925
+ }
2926
+ if (!changed)
2927
+ return;
2928
+ tx.commit();
2929
+ this.notify();
2930
+ }
2931
+ /**
2932
+ * Resize a frame to one of the `FRAME_SIZE_PRESETS` (top-left corner
2933
+ * stays put; members keep their absolute positions). One undo step.
2934
+ */
2935
+ applyFramePreset(id, preset) {
2936
+ if (this.readOnly)
2937
+ return;
2938
+ const shape = getElement(this._scene, id);
2939
+ if (!shape || !isFrame(shape))
2940
+ return;
2941
+ const r = updateElement(this._scene, id, (s) => ({
2942
+ ...s,
2943
+ width: preset.width,
2944
+ height: preset.height,
2945
+ }));
2946
+ this._scene = r.scene;
2947
+ this._history.push(r.patch);
2948
+ this.notify();
2949
+ }
2950
+ /**
2951
+ * Rename the binary file behind an image (the toolbar's file-name
2952
+ * input). One undo step; no-op when the id is unknown or the name is
2953
+ * unchanged.
2954
+ */
2955
+ renameBinaryFile(id, name) {
2956
+ if (this.readOnly)
2957
+ return;
2958
+ const file = getBinaryFile(this._scene, id);
2959
+ if (!file || file.name === name)
2960
+ return;
2961
+ const patch = { kind: "file", id, before: file, after: { ...file, name } };
2962
+ this._scene = apply(this._scene, patch);
2963
+ this._history.push(patch);
2964
+ this.notify();
2965
+ }
2966
+ /** Set (or clear, with `null`) the accessible description on image shapes. */
2967
+ setImageAlt(ids, alt) {
2968
+ if (this.readOnly)
2969
+ return;
2970
+ const tx = this._history.transaction();
2971
+ let changed = false;
2972
+ for (const id of ids) {
2973
+ const shape = getElement(this._scene, id);
2974
+ if (!shape || !isImage(shape))
2975
+ continue;
2976
+ const r = updateElement(this._scene, id, (s) => {
2977
+ const copy = { ...s };
2978
+ if (alt === null || alt === "")
2979
+ delete copy.alt;
2980
+ else
2981
+ copy.alt = alt;
2982
+ return copy;
2983
+ });
2984
+ this._scene = r.scene;
2985
+ tx.add(r.patch);
2986
+ changed = true;
2987
+ }
2988
+ if (!changed)
2989
+ return;
2990
+ tx.commit();
2991
+ this.notify();
2992
+ }
2993
+ /**
2994
+ * Set (or clear, with `null`) the shape mask on image elements. The
2995
+ * mask clips the drawn box (renderer-side `RenderTarget.clip`);
2996
+ * normalised coordinates make it survive resizes. One undo step.
2997
+ */
2998
+ setImageMask(ids, mask) {
2999
+ if (this.readOnly)
3000
+ return;
3001
+ const tx = this._history.transaction();
3002
+ let changed = false;
3003
+ for (const id of ids) {
3004
+ const shape = getElement(this._scene, id);
3005
+ if (!shape || !isImage(shape))
3006
+ continue;
3007
+ const r = updateElement(this._scene, id, (s) => {
3008
+ const copy = { ...s };
3009
+ if (mask === null)
3010
+ delete copy.mask;
3011
+ else
3012
+ copy.mask = mask;
3013
+ return copy;
3014
+ });
3015
+ this._scene = r.scene;
3016
+ tx.add(r.patch);
3017
+ changed = true;
3018
+ }
3019
+ if (!changed)
3020
+ return;
3021
+ tx.commit();
3022
+ this.notify();
3023
+ }
3024
+ /**
3025
+ * Apply an aspect preset from the mask picker to image elements.
3026
+ * `"original"` clears crop AND mask and refits the box to the source's
3027
+ * natural aspect; the ratio presets centre-crop the source to the
3028
+ * target aspect and refit the box height (width kept), with `"circle"`
3029
+ * additionally installing an ellipse mask on the square box. The
3030
+ * source's natural aspect derives from the current box + crop (the box
3031
+ * always displays the crop region undistorted), so no live image
3032
+ * handle is needed. One undo step.
3033
+ */
3034
+ setImageAspectPreset(ids, preset) {
3035
+ if (this.readOnly)
3036
+ return;
3037
+ const tx = this._history.transaction();
3038
+ let changed = false;
3039
+ for (const id of ids) {
3040
+ const shape = getElement(this._scene, id);
3041
+ if (!shape || !isImage(shape))
3042
+ continue;
3043
+ const crop = shape.crop ?? { x: 0, y: 0, width: 1, height: 1 };
3044
+ const naturalAspect = shape.height > 0 && crop.width > 0
3045
+ ? (shape.width / shape.height) * (crop.height / crop.width)
3046
+ : 1;
3047
+ const r = updateElement(this._scene, id, (s) => {
3048
+ const copy = { ...s };
3049
+ if (preset === "original") {
3050
+ delete copy.crop;
3051
+ delete copy.mask;
3052
+ if (naturalAspect > 0)
3053
+ copy.height = copy.width / naturalAspect;
3054
+ return copy;
3055
+ }
3056
+ const aspect = IMAGE_ASPECT_PRESETS[preset];
3057
+ // Centred source crop with the target aspect: trim the longer axis.
3058
+ if (naturalAspect > aspect) {
3059
+ const w = aspect / naturalAspect;
3060
+ copy.crop = { x: (1 - w) / 2, y: 0, width: w, height: 1 };
3061
+ }
3062
+ else {
3063
+ const h = naturalAspect / aspect;
3064
+ copy.crop = { x: 0, y: (1 - h) / 2, width: 1, height: h };
3065
+ }
3066
+ copy.height = copy.width / aspect;
3067
+ if (preset === "circle")
3068
+ copy.mask = { kind: "ellipse" };
3069
+ else
3070
+ delete copy.mask;
3071
+ return copy;
3072
+ });
3073
+ this._scene = r.scene;
3074
+ tx.add(r.patch);
3075
+ changed = true;
3076
+ }
3077
+ if (!changed)
3078
+ return;
3079
+ tx.commit();
3080
+ this.notify();
3081
+ }
3082
+ /**
3083
+ * Swap a media shape's backing file for `blob` while keeping its
3084
+ * position and width (height refits to the new aspect). Accepts static
3085
+ * images, GIFs and videos — the shape's animation fields are rewritten
3086
+ * to match the NEW media kind, and `crop` resets when the media kind
3087
+ * changes (its pixel rect targeted the old content). Registers the new
3088
+ * `BinaryFile`, points the shape at it, drops the stale live handle
3089
+ * (`metadata.image`) and rebuilds handles for the new bytes. One undo
3090
+ * step.
3091
+ */
3092
+ async replaceImageFile(id, blob, name) {
3093
+ if (this.readOnly)
3094
+ return;
3095
+ const shape = getElement(this._scene, id);
3096
+ if (!shape || !isImage(shape))
3097
+ return;
3098
+ const added = await computeAddBinaryFile(this._scene, blob, name, () => ++this.nextId);
3099
+ const mime = blob.type;
3100
+ const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(name ?? "");
3101
+ const isGif = !isVideo && (mime === "image/gif" || /\.gif$/i.test(name ?? ""));
3102
+ const url = typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
3103
+ ? URL.createObjectURL(blob)
3104
+ : null;
3105
+ // The new media's aspect ratio replaces the old one: keep the shape's
3106
+ // width, refit the height — otherwise a differently-proportioned file
3107
+ // renders stretched into the old box. Videos measure through a hidden
3108
+ // looping <video> (which then stays on as the live handle, same as the
3109
+ // video drop handler); images/GIFs through createImageBitmap.
3110
+ let refitHeight = null;
3111
+ let videoHandle = null;
3112
+ if (isVideo) {
3113
+ if (typeof document !== "undefined" && url !== null) {
3114
+ const video = createHiddenLoopingVideo(url);
3115
+ await new Promise((resolve) => {
3116
+ video.onloadedmetadata = () => {
3117
+ resolve();
3118
+ };
3119
+ video.onerror = () => {
3120
+ resolve();
3121
+ };
3122
+ });
3123
+ if (video.videoWidth > 0) {
3124
+ refitHeight = (shape.width * video.videoHeight) / video.videoWidth;
3125
+ }
3126
+ // Best-effort play (muted autoplay can still need a prior gesture).
3127
+ void video.play().catch(() => {
3128
+ /* intentional no-op */
3129
+ });
3130
+ videoHandle = video;
3131
+ }
3132
+ }
3133
+ else if (typeof createImageBitmap === "function") {
3134
+ try {
3135
+ const bitmap = await createImageBitmap(blob);
3136
+ if (bitmap.width > 0) {
3137
+ refitHeight = (shape.width * bitmap.height) / bitmap.width;
3138
+ }
3139
+ bitmap.close();
3140
+ }
3141
+ catch {
3142
+ /* undecodable here — keep the old box; rehydration reports errors */
3143
+ }
3144
+ }
3145
+ // GIF frames decode from the raw bytes via the "gif" adapter.
3146
+ const animationBytes = isGif ? await blob.arrayBuffer() : undefined;
3147
+ const oldKind = shape.animationKind
3148
+ ? "gif"
3149
+ : shape.metadata?.animated === true
3150
+ ? "video"
3151
+ : "static";
3152
+ const newKind = isGif ? "gif" : isVideo ? "video" : "static";
3153
+ const tx = this._history.transaction();
3154
+ tx.add(added.patch);
3155
+ const r = updateElement(added.scene, id, (s) => {
3156
+ const copy = { ...s };
3157
+ copy.fileId = added.id;
3158
+ if (url !== null)
3159
+ copy.src = url;
3160
+ if (refitHeight !== null)
3161
+ copy.height = refitHeight;
3162
+ const meta = { ...copy.metadata };
3163
+ delete meta.image;
3164
+ delete meta.animated;
3165
+ if (newKind !== "static")
3166
+ meta.animated = true;
3167
+ if (videoHandle)
3168
+ meta.image = videoHandle;
3169
+ if (Object.keys(meta).length > 0)
3170
+ copy.metadata = meta;
3171
+ else
3172
+ delete copy.metadata;
3173
+ delete copy.animationKind;
3174
+ delete copy.animationData;
3175
+ if (isGif) {
3176
+ copy.animationKind = "gif";
3177
+ copy.animationData = animationBytes;
3178
+ }
3179
+ // The crop rect addressed the OLD media's pixels — meaningless
3180
+ // across a media-kind switch.
3181
+ if (oldKind !== newKind)
3182
+ delete copy.crop;
3183
+ return copy;
3184
+ });
3185
+ this._scene = r.scene;
3186
+ tx.add(r.patch);
3187
+ tx.commit();
3188
+ if (newKind !== "static") {
3189
+ this.gifPlayback.ensure(id);
3190
+ this.maybeAnimate();
3191
+ }
3192
+ this.notify();
3193
+ if (newKind === "static")
3194
+ await animScene.rehydrateStaticImages(this);
3195
+ }
3196
+ /**
3197
+ * Paragraph targets for the list operations: while inline-editing one
3198
+ * of `ids`, only the paragraphs covered by the caret / selection;
3199
+ * otherwise every paragraph of each text element. Returns `null` for
3200
+ * non-text ids.
3201
+ */
3202
+ paragraphTargets(id) {
3203
+ const shape = getElement(this._scene, id);
3204
+ if (!shape || !isText(shape))
3205
+ return null;
3206
+ const sel = this.editingTextElement === id ? this.editingTextSelection : null;
3207
+ if (sel)
3208
+ return paragraphRangeForOffsets(shape.text, sel.start, sel.end);
3209
+ return { first: 0, last: paragraphCount(shape.text) - 1 };
3210
+ }
3211
+ /**
3212
+ * Set (or clear, with `null`) the list kind on the targeted paragraphs
3213
+ * of every text element in `ids` — the inline-edit selection's
3214
+ * paragraphs while editing, the whole element otherwise. One undo step.
3215
+ */
3216
+ setParagraphList(ids, kind) {
3217
+ if (this.readOnly)
3218
+ return;
3219
+ const tx = this._history.transaction();
3220
+ let changed = false;
3221
+ for (const id of ids) {
3222
+ const range = this.paragraphTargets(id);
3223
+ if (!range)
3224
+ continue;
3225
+ const r = updateElement(this._scene, id, (s) => {
3226
+ const shape = s;
3227
+ const count = paragraphCount(shape.text);
3228
+ const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
3229
+ if (i < range.first || i > range.last)
3230
+ return p;
3231
+ const { list: _list, ...rest } = p;
3232
+ return kind === null ? rest : { ...rest, list: kind };
3233
+ });
3234
+ const normalized = normalizeParagraphs(next);
3235
+ const copy = { ...s };
3236
+ if (normalized === undefined)
3237
+ delete copy.paragraphs;
3238
+ else
3239
+ copy.paragraphs = normalized;
3240
+ return copy;
3241
+ });
3242
+ this._scene = r.scene;
3243
+ tx.add(r.patch);
3244
+ changed = true;
3245
+ }
3246
+ if (!changed)
3247
+ return;
3248
+ tx.commit();
3249
+ this.notify();
3250
+ }
3251
+ /**
3252
+ * Shift the targeted paragraphs' list nesting by `delta` (±1), clamped
3253
+ * to `[0, MAX_LIST_INDENT]`. Same targeting rules as
3254
+ * {@link setParagraphList}. One undo step.
3255
+ */
3256
+ indentParagraphs(ids, delta) {
3257
+ if (this.readOnly)
3258
+ return;
3259
+ const tx = this._history.transaction();
3260
+ let changed = false;
3261
+ for (const id of ids) {
3262
+ const range = this.paragraphTargets(id);
3263
+ if (!range)
3264
+ continue;
3265
+ const r = updateElement(this._scene, id, (s) => {
3266
+ const shape = s;
3267
+ const count = paragraphCount(shape.text);
3268
+ const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
3269
+ if (i < range.first || i > range.last)
3270
+ return p;
3271
+ const level = Math.max(0, Math.min(MAX_LIST_INDENT, (p.indent ?? 0) + delta));
3272
+ const { indent: _indent, ...rest } = p;
3273
+ return level === 0 ? rest : { ...rest, indent: level };
3274
+ });
3275
+ const normalized = normalizeParagraphs(next);
3276
+ const copy = { ...s };
3277
+ if (normalized === undefined)
3278
+ delete copy.paragraphs;
3279
+ else
3280
+ copy.paragraphs = normalized;
3281
+ return copy;
3282
+ });
3283
+ this._scene = r.scene;
3284
+ tx.add(r.patch);
3285
+ changed = true;
3286
+ }
3287
+ if (!changed)
3288
+ return;
3289
+ tx.commit();
3290
+ this.notify();
3291
+ }
2456
3292
  // --- F8: Eyedropper ---------------------------------------------------
2457
3293
  /**
2458
3294
  * The fill (or stroke, per `role`) colour of the top-most shape under the
@@ -2946,8 +3782,8 @@ export class Editor {
2946
3782
  * with its world bounds — for the hover link-popup. `null` when none.
2947
3783
  */
2948
3784
  linkAt(worldPoint) {
2949
- const shape = this.acceleratedElementAt(worldPoint);
2950
- if (!shape || !this.isElementInteractable(shape))
3785
+ const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
3786
+ if (!shape)
2951
3787
  return null;
2952
3788
  const href = safeHref(shape.href);
2953
3789
  if (!href)
@@ -3026,20 +3862,37 @@ export class Editor {
3026
3862
  * rotate gesture drives the same maths from a press-time snapshot.
3027
3863
  */
3028
3864
  rotateSelection(delta) {
3029
- const elements = [];
3865
+ // Groups carry their subtree: every descendant orbits the shared pivot.
3030
3866
  const origin = new Map();
3031
- for (const id of this._selection) {
3867
+ for (const id of this.expandSelectionWithDescendants()) {
3032
3868
  const el = getElement(this._scene, id);
3033
- if (el === undefined)
3869
+ if (el === undefined || isGroup(el))
3034
3870
  continue;
3035
- elements.push(el);
3036
3871
  origin.set(id, { position: el.position, rotation: el.rotation });
3037
3872
  }
3038
- if (elements.length === 0)
3873
+ const box = this.combinedSelectionBounds();
3874
+ if (origin.size === 0 || !box)
3039
3875
  return;
3040
- const pivot = selectionCenter(elements);
3876
+ const pivot = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
3041
3877
  this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
3042
3878
  }
3879
+ /**
3880
+ * Straighten the selection: every selected element (and descendants of
3881
+ * selected groups) goes back to `rotation: 0`, each turning about its own
3882
+ * centre so it stays where it is. One undoable step; no-op when nothing
3883
+ * is rotated. Triggered by a double-click on the rotate grip.
3884
+ */
3885
+ resetSelectionRotation() {
3886
+ const patches = [];
3887
+ for (const id of this.expandSelectionWithDescendants()) {
3888
+ const el = getElement(this._scene, id);
3889
+ if (el === undefined || el.rotation === 0)
3890
+ continue;
3891
+ const origin = new Map([[id, { position: el.position, rotation: el.rotation }]]);
3892
+ patches.push(...computeRotatePatches(this._scene, origin, selectionCenter([el]), -el.rotation));
3893
+ }
3894
+ this.commitArrange(patches);
3895
+ }
3043
3896
  /** Apply a batch of arrange patches as a single undoable step. */
3044
3897
  commitArrange(patches) {
3045
3898
  if (this.readOnly)
@@ -3191,6 +4044,18 @@ export class Editor {
3191
4044
  const center = this.screenToWorld({ x: vp.size.width / 2, y: vp.size.height / 2 });
3192
4045
  this.zoomAt(factor, center);
3193
4046
  }
4047
+ /**
4048
+ * Set an absolute zoom level (1 = 100%), scaling about the viewport
4049
+ * centre and clamped to `MIN_ZOOM..MAX_ZOOM`. The zoom menu's presets.
4050
+ */
4051
+ setZoom(level) {
4052
+ const vp = this._scene.viewport;
4053
+ const current = vp.zoom || 1;
4054
+ const target = clampZoom(level);
4055
+ if (target === current || vp.size.width <= 0 || vp.size.height <= 0)
4056
+ return;
4057
+ this.zoomStep(target / current);
4058
+ }
3194
4059
  resetZoom() {
3195
4060
  const next = computeResetZoom(this._scene);
3196
4061
  if (!next)
@@ -3232,6 +4097,20 @@ export class Editor {
3232
4097
  this._scene = next;
3233
4098
  this.notify();
3234
4099
  }
4100
+ /**
4101
+ * "Back to content": jump to the element nearest the camera centre, keeping
4102
+ * the current zoom (it only drops when that element doesn't fit). Unlike
4103
+ * {@link zoomToFit} this neither shrinks a large board to a speck nor blows
4104
+ * a single small shape up to full screen. Groups are skipped (their own
4105
+ * bounds are empty — their children count). No-op on an empty scene.
4106
+ */
4107
+ revealNearestContent(padding = 80) {
4108
+ const next = computeRevealNearest(this._scene, padding, (s) => s.type !== "group");
4109
+ if (!next)
4110
+ return;
4111
+ this._scene = next;
4112
+ this.notify();
4113
+ }
3235
4114
  /**
3236
4115
  * Select the nearest interactable top-level element in `direction` from the
3237
4116
  * current selection's centre (or the viewport centre when nothing is
@@ -3314,6 +4193,137 @@ export class Editor {
3314
4193
  snapSpacing() {
3315
4194
  return resolveSnapSpacing();
3316
4195
  }
4196
+ /**
4197
+ * Object snapping is a per-user preference; the suppress modifier
4198
+ * (Cmd/Ctrl) bypasses it for the current gesture like grid snapping.
4199
+ */
4200
+ objectSnapActive() {
4201
+ return !this.interaction.snapSuppressed && this._preferences.snapObjects;
4202
+ }
4203
+ sizeSuggestActive() {
4204
+ return !this.interaction.snapSuppressed && this._preferences.suggestObjectSize;
4205
+ }
4206
+ /**
4207
+ * World AABBs of the shapes a gesture may snap to (reference rules): every
4208
+ * visible element on screen that is not being moved / resized, except
4209
+ * groups (no geometry of their own), brush strokes (freehand ink is never
4210
+ * a snap target), shapes rotated off the 90° grid, and shapes too small on
4211
+ * screen (`OBJECT_SNAP_MIN_SIZE_PX`). Returns nothing on a crowded view
4212
+ * (`OBJECT_SNAP_MAX_CANDIDATES`) so a huge board never stalls a drag.
4213
+ */
4214
+ snapCandidates(moving) {
4215
+ const view = this.computeViewportWorld();
4216
+ const zoom = this._scene.viewport.zoom || 1;
4217
+ const minSize = OBJECT_SNAP_MIN_SIZE_PX / zoom;
4218
+ const out = [];
4219
+ for (const shape of this._scene.elements.values()) {
4220
+ if (moving.has(shape.id) || isGroup(shape) || isBrush(shape))
4221
+ continue;
4222
+ if (!isRightAngle(shape.rotation))
4223
+ continue;
4224
+ if (isElementHidden(this._scene, shape))
4225
+ continue;
4226
+ const b = getElementWorldBounds(shape);
4227
+ if (view && !B.intersects(view, b))
4228
+ continue;
4229
+ if (b.width < minSize && b.height < minSize)
4230
+ continue;
4231
+ out.push(b);
4232
+ if (out.length > OBJECT_SNAP_MAX_CANDIDATES)
4233
+ return [];
4234
+ }
4235
+ return out;
4236
+ }
4237
+ /** Brush strokes never snap (as movers or targets) — freehand ink has no meaningful edges. */
4238
+ snapsAsMover(id) {
4239
+ const shape = getElement(this._scene, id);
4240
+ return shape !== undefined && !isBrush(shape);
4241
+ }
4242
+ /**
4243
+ * Move-delta snapping: object snapping (edges / centres of nearby shapes)
4244
+ * wins when it lands; otherwise grid snapping. Records the guides for the
4245
+ * overlay either way.
4246
+ */
4247
+ snapMoveDeltaFor(moving, bounds, delta) {
4248
+ this.interaction.snapGuides = [];
4249
+ if (this.objectSnapActive() && [...moving].some((id) => this.snapsAsMover(id))) {
4250
+ const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
4251
+ const r = snapMoveDeltaToObjects(bounds, delta, this.snapCandidates(moving), threshold);
4252
+ if (r.guides.length > 0) {
4253
+ this.interaction.snapGuides = r.guides;
4254
+ return r.delta;
4255
+ }
4256
+ }
4257
+ return this.snapActive() ? snapMoveDelta(bounds, delta, this.snapSpacing()) : delta;
4258
+ }
4259
+ /**
4260
+ * Group move: the press-time union AABB of the members (each member's
4261
+ * current geometry at its press-time position; brush strokes excluded)
4262
+ * snaps to nearby shapes by its frame EDGES only — a multi-selection has
4263
+ * no centre line, like the reference. Falls back to the grid
4264
+ * reference-point snap.
4265
+ */
4266
+ snapGroupMoveDelta(origins, delta) {
4267
+ this.interaction.snapGuides = [];
4268
+ if (this.objectSnapActive()) {
4269
+ let union = null;
4270
+ let members = 0;
4271
+ for (const [id, position] of origins) {
4272
+ const shape = getElement(this._scene, id);
4273
+ if (!shape || isGroup(shape) || isBrush(shape))
4274
+ continue;
4275
+ members += 1;
4276
+ const b = getElementWorldBounds({ ...shape, position });
4277
+ union = union ? B.union(union, b) : b;
4278
+ }
4279
+ if (union) {
4280
+ const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
4281
+ const r = snapMoveDeltaToObjects(union, delta, this.snapCandidates(new Set(origins.keys())), threshold, { centerLines: members === 1 });
4282
+ if (r.guides.length > 0) {
4283
+ this.interaction.snapGuides = r.guides;
4284
+ return r.delta;
4285
+ }
4286
+ }
4287
+ }
4288
+ return this.snapActive() ? snapGroupDelta(origins, delta, this.snapSpacing()) : delta;
4289
+ }
4290
+ /** Resize-delta counterpart of {@link snapMoveDeltaFor}; also sets `sizeMatch`. */
4291
+ snapResizeDeltaFor(moving, original, handle, delta) {
4292
+ this.interaction.snapGuides = [];
4293
+ this.interaction.sizeMatch = null;
4294
+ const alignEdges = this.objectSnapActive();
4295
+ const matchSizes = this.sizeSuggestActive();
4296
+ if ((alignEdges || matchSizes) && [...moving].some((id) => this.snapsAsMover(id))) {
4297
+ const zoom = this._scene.viewport.zoom || 1;
4298
+ const threshold = Math.max(OBJECT_SNAP_THRESHOLD_PX, SIZE_SUGGEST_THRESHOLD_PX) / zoom;
4299
+ const r = snapResizeDeltaToObjects(original, handle, delta, this.snapCandidates(moving), threshold, { alignEdges, matchSizes });
4300
+ if (r.guides.length > 0 || r.sizeMatch !== null) {
4301
+ this.interaction.snapGuides = r.guides;
4302
+ this.interaction.sizeMatch = r.sizeMatch;
4303
+ return r.delta;
4304
+ }
4305
+ }
4306
+ return this.snapActive() ? snapResizeDelta(original, handle, delta, this.snapSpacing()) : delta;
4307
+ }
4308
+ /** Publish the `W × H` readout for `bounds` when the preference is on. */
4309
+ setSizeReadout(bounds) {
4310
+ this.interaction.sizeReadout =
4311
+ bounds && this._preferences.showObjectSize
4312
+ ? { bounds, width: bounds.width, height: bounds.height }
4313
+ : null;
4314
+ }
4315
+ /** Alignment guides of the current gesture tick (object snapping). */
4316
+ get snapGuides() {
4317
+ return this.interaction.snapGuides;
4318
+ }
4319
+ /** `W × H` readout of the shape being resized, when `showObjectSize` is on. */
4320
+ get sizeReadout() {
4321
+ return this.interaction.sizeReadout;
4322
+ }
4323
+ /** Bounds of the shape whose size the current resize matched, if any. */
4324
+ get sizeMatch() {
4325
+ return this.interaction.sizeMatch;
4326
+ }
3317
4327
  /**
3318
4328
  * Replace the entire scene (e.g. after `parseScene`). Clears history,
3319
4329
  * selection and any open gesture. Use to load a saved document.
@@ -3323,7 +4333,9 @@ export class Editor {
3323
4333
  this.gestureTx.cancel();
3324
4334
  this.gestureTx = null;
3325
4335
  }
3326
- this._scene = scene;
4336
+ // A saved start view is where a freshly opened document lands; a
4337
+ // peer update (`preserveHistory`) must not yank the local camera.
4338
+ this._scene = options.preserveHistory ? scene : withStartViewApplied(scene);
3327
4339
  // Snap active layer back into the loaded scene's layer set.
3328
4340
  if (!scene.layers.has(this._activeLayerId)) {
3329
4341
  const first = scene.layers.keys().next().value;
@@ -3358,6 +4370,7 @@ export class Editor {
3358
4370
  }
3359
4371
  /** Detach all DOM listeners and stop the actor. */
3360
4372
  dispose() {
4373
+ this.disposed = true;
3361
4374
  this.cancelLongPress();
3362
4375
  this.unbind();
3363
4376
  this.actor.stop();
@@ -3410,10 +4423,10 @@ export class Editor {
3410
4423
  // Right-click without a drag → trigger the context-menu listeners.
3411
4424
  // Same payload as touch long-press so existing UI (e.g.
3412
4425
  // `@react-ui/ContextMenu`) works without changes.
3413
- const worldPoint = this.screenToWorld(gesture.startPoint);
3414
- for (const fn of this.longPressListeners) {
3415
- fn({ screenPoint: gesture.startPoint, worldPoint });
3416
- }
4426
+ this.fireContextPress({
4427
+ screenPoint: gesture.startPoint,
4428
+ worldPoint: this.screenToWorld(gesture.startPoint),
4429
+ });
3417
4430
  }
3418
4431
  else {
3419
4432
  // Either it was a real drag, or Space + left drag. In both cases the
@@ -3424,9 +4437,48 @@ export class Editor {
3424
4437
  // Pan over — recompute (→ "grab" if Space/hand still armed, else the
3425
4438
  // idle hover cursor).
3426
4439
  this.refreshCursor();
4440
+ // The gesture's end is itself a change: idle-gated observers (the
4441
+ // minimap) skip every pan notify while `panGesture` is set and rely on
4442
+ // this trailing one to repaint.
4443
+ this.notify();
3427
4444
  }
3428
4445
  isDrawingPhase(ctx) {
3429
- return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
4446
+ return (ctx.mode === "draw-rect" ||
4447
+ ctx.mode === "draw-ellipse" ||
4448
+ ctx.mode === "draw-frame" ||
4449
+ ctx.mode === "draw-edge");
4450
+ }
4451
+ /**
4452
+ * Context press (right-click / touch long-press): first make the selection
4453
+ * match what was pressed, then notify the `onLongPress` listeners so the
4454
+ * menu they open describes that point.
4455
+ * - On an unselected element / link → it becomes the selection.
4456
+ * - On a selected element (or its chrome: handles, rotate grip) → keep.
4457
+ * - On empty canvas → clear elements + links, so the menu is the canvas
4458
+ * menu rather than the previous selection's.
4459
+ */
4460
+ fireContextPress(payload) {
4461
+ this.routeContextPress(payload.worldPoint);
4462
+ for (const fn of this.longPressListeners)
4463
+ fn(payload);
4464
+ }
4465
+ routeContextPress(worldPoint) {
4466
+ const target = this.hitTest(worldPoint);
4467
+ if (target.kind === "element") {
4468
+ if (!this._selection.has(target.id)) {
4469
+ this.applyEmit({ type: "SELECT_REPLACE", id: target.id });
4470
+ }
4471
+ }
4472
+ else if (target.kind === "link") {
4473
+ if (!this._selectedLinks.has(target.id)) {
4474
+ this.applyEmit({ type: "SELECT_EDGE_REPLACE", id: target.id });
4475
+ }
4476
+ }
4477
+ else if (target.kind === "empty") {
4478
+ if (this._selection.size > 0 || this._selectedLinks.size > 0) {
4479
+ this.applyEmit({ type: "SELECT_CLEAR" });
4480
+ }
4481
+ }
3430
4482
  }
3431
4483
  // --- Long-press ---
3432
4484
  startLongPress(screenPoint) {
@@ -3462,8 +4514,8 @@ export class Editor {
3462
4514
  * `undefined` → dropped on empty space (the end stays a free point).
3463
4515
  */
3464
4516
  linkAttachTargetAt(worldPoint) {
3465
- const shape = this.acceleratedElementAt(worldPoint);
3466
- if (shape && this.isElementInteractable(shape)) {
4517
+ const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
4518
+ if (shape) {
3467
4519
  const target = this.promoteToGroupRoot(shape);
3468
4520
  return { kind: "element", id: target.id, bounds: getElementWorldBounds(target) };
3469
4521
  }
@@ -3486,8 +4538,9 @@ export class Editor {
3486
4538
  hitAnnotation: (p) => this.hitAnnotation(p),
3487
4539
  selectionIsAspectLocked: () => this.selectionIsAspectLocked(),
3488
4540
  combinedSelectionBounds: () => this.combinedSelectionBounds(),
3489
- acceleratedElementAt: (p) => this.acceleratedElementAt(p),
4541
+ acceleratedElementAt: (p, accept) => this.acceleratedElementAt(p, accept),
3490
4542
  isElementInteractable: (s) => this.isElementInteractable(s),
4543
+ isElementManipulable: (s) => this.isElementManipulable(s),
3491
4544
  isLayerLocked: (id) => this.isLayerLocked(id),
3492
4545
  promoteToGroupRoot: (s) => this.promoteToGroupRoot(s),
3493
4546
  });
@@ -3542,10 +4595,12 @@ export class Editor {
3542
4595
  return false;
3543
4596
  if (isElementHidden(this._scene, shape))
3544
4597
  return false;
3545
- // NOTE: a `locked` element IS interactable for SELECTION (so the user can
3546
- // click it to unlock) movement / resize are blocked separately via
3547
- // `isElementManipulable`. Click-through past a locked shape is therefore
3548
- // disabled, matching standard.
4598
+ // A locked element is click-through: pointer hits pass to the shapes
4599
+ // beneath it (the hit-test scans past rejected candidates). Unlocking
4600
+ // goes through the context menu (`lockedElementAt` + `unlockElement`),
4601
+ // not through selection.
4602
+ if (isElementLocked(this._scene, shape))
4603
+ return false;
3549
4604
  return true;
3550
4605
  }
3551
4606
  /**
@@ -3590,8 +4645,56 @@ export class Editor {
3590
4645
  tx.add(r.patch);
3591
4646
  }
3592
4647
  tx.commit();
4648
+ // Locked elements are click-through (not selectable) — keeping them
4649
+ // selected would leave a live toolbar on an element clicks can no
4650
+ // longer reach. Locking therefore drops the selection.
4651
+ if (anyUnlocked)
4652
+ this._selection = Selection.EMPTY;
3593
4653
  this.notify();
3594
4654
  }
4655
+ /**
4656
+ * Topmost locked (own or inherited `locked`) shape at `worldPoint`, on a
4657
+ * visible, unlocked layer. Locked shapes are click-through for normal
4658
+ * interaction, so this is the dedicated lookup for affordances that must
4659
+ * still find them — the context menu's Unlock entry.
4660
+ */
4661
+ lockedElementAt(worldPoint) {
4662
+ const shape = this.acceleratedElementAt(worldPoint, (s) => !this.isLayerLocked(s.layerId) &&
4663
+ !isElementHidden(this._scene, s) &&
4664
+ isElementLocked(this._scene, s));
4665
+ return shape ?? null;
4666
+ }
4667
+ /**
4668
+ * Clear the `locked` flag on the shape that carries it. When the flag
4669
+ * lives on an ancestor (group lock propagation), the closest locked
4670
+ * ancestor is unlocked instead, so the whole locked unit is released.
4671
+ * Selects the unlocked shape afterwards — the natural next step after
4672
+ * an unlock is editing it. One undo step.
4673
+ */
4674
+ unlockElement(id) {
4675
+ if (this.readOnly)
4676
+ return;
4677
+ let target = getElement(this._scene, id);
4678
+ if (!target || !isElementLocked(this._scene, target))
4679
+ return;
4680
+ // Walk up to the shape that actually carries the flag.
4681
+ while (target.locked !== true && target.parentId !== undefined) {
4682
+ const parent = getElement(this._scene, target.parentId);
4683
+ if (!parent)
4684
+ break;
4685
+ target = parent;
4686
+ }
4687
+ if (target.locked !== true)
4688
+ return;
4689
+ const r = updateElement(this._scene, target.id, (s) => {
4690
+ const copy = { ...s };
4691
+ delete copy.locked;
4692
+ return copy;
4693
+ });
4694
+ this._scene = r.scene;
4695
+ this._history.push(r.patch);
4696
+ this.setSelection([target.id]);
4697
+ }
3595
4698
  /**
3596
4699
  * Promote a hit shape to the topmost ancestor whose group we have NOT
3597
4700
  * "entered" yet. Only **`group`**-typed parents promote — containers
@@ -3754,11 +4857,11 @@ export class Editor {
3754
4857
  * scene-identity. Scene operations replace `_scene` (immutable patches),
3755
4858
  * so reference-equality is a sufficient invalidation signal.
3756
4859
  */
3757
- acceleratedElementAt(worldPoint) {
4860
+ acceleratedElementAt(worldPoint, accept) {
3758
4861
  if (this._scene.elements.size < LARGE_SCENE_HIT_THRESHOLD) {
3759
- return getElementAt(this._scene, worldPoint);
4862
+ return getElementAt(this._scene, worldPoint, accept);
3760
4863
  }
3761
- return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint);
4864
+ return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint, accept);
3762
4865
  }
3763
4866
  /**
3764
4867
  * Build (or return the cached) `SpatialGrid` for the current scene.
@@ -3801,9 +4904,7 @@ export class Editor {
3801
4904
  */
3802
4905
  routeIsolationClick(clickEffect, worldPoint) {
3803
4906
  const now = performance.now();
3804
- const isDouble = now - this.interaction.lastClickAt < DOUBLE_CLICK_MS &&
3805
- this.interaction.lastClickWorldPoint !== null &&
3806
- vec2.distance(this.interaction.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
4907
+ const isDouble = this.interaction.isDoubleClickAt(worldPoint, now);
3807
4908
  this.interaction.lastClickAt = now;
3808
4909
  this.interaction.lastClickWorldPoint = worldPoint;
3809
4910
  // Double-click the frame HEADER (label strip above the body) → rename.
@@ -3852,6 +4953,12 @@ export class Editor {
3852
4953
  const raw = this.acceleratedElementAt(worldPoint);
3853
4954
  if (raw !== undefined && isText(raw)) {
3854
4955
  this.beginTextEdit(raw.id);
4956
+ // Caret lands where the user double-clicked, not at the text end.
4957
+ // Collapsed selection WITHOUT a drag anchor — the double-click's
4958
+ // own pointer movement must not start a drag-select.
4959
+ const caretIdx = this.caretIndexAtWorldPoint(worldPoint);
4960
+ if (caretIdx !== null)
4961
+ this.setEditingSelection(caretIdx, caretIdx);
3855
4962
  return true;
3856
4963
  }
3857
4964
  if (raw !== undefined && isFrame(raw)) {
@@ -3871,6 +4978,18 @@ export class Editor {
3871
4978
  this.notify();
3872
4979
  return true;
3873
4980
  }
4981
+ // No group to drill into — double-click on a labelable shape
4982
+ // opens its embedded label editor (creating an empty label on
4983
+ // the first edit).
4984
+ if (canCarryLabel(raw)) {
4985
+ this.beginTextEdit(raw.id);
4986
+ // Same as text elements: the caret starts at the click point —
4987
+ // important for long labels whose tail is clipped by the shape.
4988
+ const labelCaretIdx = this.caretIndexAtWorldPoint(worldPoint);
4989
+ if (labelCaretIdx !== null)
4990
+ this.setEditingSelection(labelCaretIdx, labelCaretIdx);
4991
+ return true;
4992
+ }
3874
4993
  }
3875
4994
  }
3876
4995
  return false;
@@ -4066,7 +5185,9 @@ export class Editor {
4066
5185
  return;
4067
5186
  // Shift constrains the drag to one axis before snapping.
4068
5187
  const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
4069
- const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
5188
+ // Rotated shapes keep their AABB-based snapping (edges still align in
5189
+ // world space); the object pass is skipped only when nothing is nearby.
5190
+ const d = this.snapMoveDeltaFor(new Set([id]), originalBounds, moved);
4070
5191
  const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
4071
5192
  if (!patch)
4072
5193
  return;
@@ -4079,9 +5200,7 @@ export class Editor {
4079
5200
  return;
4080
5201
  // Shift constrains the drag to one axis before snapping.
4081
5202
  const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
4082
- const d = this.snapActive()
4083
- ? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
4084
- : moved;
5203
+ const d = this.snapGroupMoveDelta(this.groupMoveOrigin, moved);
4085
5204
  const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
4086
5205
  for (const patch of patches) {
4087
5206
  this._scene = apply(this._scene, patch);
@@ -4174,6 +5293,10 @@ export class Editor {
4174
5293
  // repaint when the entered-group identity changes between frames.
4175
5294
  if (this.lastRenderedEnteredGroup !== this._enteredGroup)
4176
5295
  return null;
5296
+ // Hover transition on a sticky toggles the "+" chrome without a scene
5297
+ // change — force a repaint frame for it.
5298
+ if (this.lastRenderedHoveredSticky !== this._hoveredStickyId)
5299
+ return null;
4177
5300
  // Eraser just STOPPED (the active-stroke guard above already returned, so
4178
5301
  // `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
4179
5302
  // without a scene change — commit changes the scene and is caught by the
@@ -4274,9 +5397,7 @@ export class Editor {
4274
5397
  applyGroupResize(handle, delta, originalBounds) {
4275
5398
  if (!this.groupResizeOrigin)
4276
5399
  return;
4277
- const d = this.snapActive()
4278
- ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
4279
- : delta;
5400
+ const d = this.snapResizeDeltaFor(new Set(this.groupResizeOrigin.elements.keys()), originalBounds, handle, delta);
4280
5401
  const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
4281
5402
  // Aspect-locked when the selection type demands it (images / groups) or
4282
5403
  // the user holds Shift for this gesture.
@@ -4284,6 +5405,7 @@ export class Editor {
4284
5405
  this._scene = result.scene;
4285
5406
  for (const patch of result.patches)
4286
5407
  this.recordGesturePatch(patch);
5408
+ this.setSizeReadout(this.combinedSelectionBounds());
4287
5409
  this.notify();
4288
5410
  }
4289
5411
  applyResize(id, handle, delta, originalBounds) {
@@ -4301,12 +5423,11 @@ export class Editor {
4301
5423
  return;
4302
5424
  this._scene = result.scene;
4303
5425
  this.recordGesturePatch(result.patch);
5426
+ this.setSizeReadout(this.resizedBounds(id));
4304
5427
  this.notify();
4305
5428
  return;
4306
5429
  }
4307
- const d = this.snapActive()
4308
- ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
4309
- : delta;
5430
+ const d = this.snapResizeDeltaFor(new Set([id]), originalBounds, handle, delta);
4310
5431
  // Text: aspect-locked font scaling. Snapshot the pristine shape on
4311
5432
  // the gesture's first tick so the scale base never compounds.
4312
5433
  if (shape !== undefined && isText(shape)) {
@@ -4322,6 +5443,7 @@ export class Editor {
4322
5443
  return;
4323
5444
  this._scene = result.scene;
4324
5445
  this.recordGesturePatch(result.patch);
5446
+ this.setSizeReadout(this.resizedBounds(id));
4325
5447
  this.notify();
4326
5448
  return;
4327
5449
  }
@@ -4330,12 +5452,32 @@ export class Editor {
4330
5452
  return;
4331
5453
  this._scene = result.scene;
4332
5454
  this.recordGesturePatch(result.patch);
5455
+ this.setSizeReadout(this.resizedBounds(id));
4333
5456
  this.notify();
4334
5457
  }
5458
+ /** World AABB of `id` after a resize tick (null when the shape is gone). */
5459
+ resizedBounds(id) {
5460
+ const shape = getElement(this._scene, id);
5461
+ return shape ? getElementWorldBounds(shape) : null;
5462
+ }
5463
+ /**
5464
+ * Snap freshly-drawn bounds onto the grid when snapping is active.
5465
+ * Shared by the live rubber-band preview and the final CREATE so the
5466
+ * preview shows exactly where the shape will land (parity with how
5467
+ * move / resize snap live during the gesture).
5468
+ */
5469
+ snapCreateBoundsIfActive(bounds) {
5470
+ return this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
5471
+ }
4335
5472
  applyCreate(kind, bounds) {
4336
5473
  const id = newElementId(++this.nextId);
4337
- const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
4338
- const result = computeCreateElement(this._scene, kind, b, id, this._activeLayerId, () => this.nextFrameName());
5474
+ const b = this.snapCreateBoundsIfActive(bounds);
5475
+ // The machine only knows the MODE ("rect" for draw-rect); the flyout
5476
+ // may have armed a diamond / triangle variant on top of it.
5477
+ const effectiveKind = kind === "rect" && this._drawShapeKind !== "rect" && this._drawShapeKind !== "ellipse"
5478
+ ? this._drawShapeKind
5479
+ : kind;
5480
+ const result = computeCreateElement(this._scene, effectiveKind, b, id, this._activeLayerId, () => this.nextFrameName());
4339
5481
  this._scene = result.scene;
4340
5482
  this._selection = Selection.single(id);
4341
5483
  // CREATE is a single-shot operation, not part of a multi-tick gesture.
@@ -4376,7 +5518,7 @@ export class Editor {
4376
5518
  const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
4377
5519
  const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
4378
5520
  const id = newLinkId(++this.nextId);
4379
- const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId);
5521
+ const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId, this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined);
4380
5522
  this._scene = result.scene;
4381
5523
  this._history.push(result.patch);
4382
5524
  this.edgePreview = null;
@@ -4477,7 +5619,7 @@ export class Editor {
4477
5619
  // The wrappers here own the side effects (`_selectedLink`
4478
5620
  // clearing, notify).
4479
5621
  applySelectByBounds(bounds, mode) {
4480
- const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
5622
+ const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
4481
5623
  const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
4482
5624
  const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
4483
5625
  this._selectedLinks = nextLinks;
@@ -4490,7 +5632,7 @@ export class Editor {
4490
5632
  }
4491
5633
  applyLassoLiveSelection(bounds, mode) {
4492
5634
  const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
4493
- const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
5635
+ const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
4494
5636
  const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
4495
5637
  const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
4496
5638
  const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
@@ -4687,9 +5829,12 @@ export class Editor {
4687
5829
  }
4688
5830
  applyLinkPreview(fromElement, fromPoint, toPoint) {
4689
5831
  const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
4690
- // Match the preview to the connector that will be committed: when new
4691
- // links default to elbow, draw the orthogonal route, not a straight line.
4692
- if (DEFAULT_LINK_ROUTING === "orthogonal") {
5832
+ // Match the preview to the connector that will be committed: elbow
5833
+ // presets/defaults draw the orthogonal route, straight presets a line.
5834
+ const previewRouting = this._linkDrawPreset !== null
5835
+ ? LINK_DRAW_PRESETS[this._linkDrawPreset].routing
5836
+ : DEFAULT_LINK_ROUTING;
5837
+ if (previewRouting === "orthogonal") {
4693
5838
  const hit = this.hitTest(ep.to);
4694
5839
  const toElement = hit.kind === "element" ? hit.id : null;
4695
5840
  const points = routeElbowPreview(this._scene, fromElement, ep.from, toElement, ep.to);
@@ -4713,6 +5858,7 @@ export class Editor {
4713
5858
  }
4714
5859
  commitGesture() {
4715
5860
  this.interaction.resizeOriginElement = null;
5861
+ this.interaction.clearSnapAssists();
4716
5862
  this.rotateGestureOrigin = null;
4717
5863
  this.gestures.commit();
4718
5864
  this.gestureStartScene = null;
@@ -4758,6 +5904,7 @@ export class Editor {
4758
5904
  }
4759
5905
  cancelGesture() {
4760
5906
  this.interaction.resizeOriginElement = null;
5907
+ this.interaction.clearSnapAssists();
4761
5908
  this.interaction.rotateGestureOrigin = null;
4762
5909
  this.gestures.cancel();
4763
5910
  // Roll the scene back to the pre-gesture snapshot — cancelling the history
@@ -4801,6 +5948,13 @@ export class Editor {
4801
5948
  * into a single render per frame.
4802
5949
  */
4803
5950
  renderRafId = null;
5951
+ /**
5952
+ * Set by {@link dispose}. Render scheduling bails out when set:
5953
+ * async completions (image decode, font load, wasm init) can resolve
5954
+ * after the host tears the editor down (e.g. a runtime backend
5955
+ * switch) and must not paint onto disposed render targets.
5956
+ */
5957
+ disposed = false;
4804
5958
  /** Unsubscribe for the animation-content-ready listener (decode → re-render). */
4805
5959
  animationContentOff = null;
4806
5960
  /**
@@ -4816,6 +5970,8 @@ export class Editor {
4816
5970
  * compare bitmap output after a mutation).
4817
5971
  */
4818
5972
  scheduleRender() {
5973
+ if (this.disposed)
5974
+ return; // async completions (image decode, font load) may outlive the editor
4819
5975
  if (this.renderRafId !== null)
4820
5976
  return;
4821
5977
  if (typeof requestAnimationFrame === "undefined") {
@@ -4841,6 +5997,8 @@ export class Editor {
4841
5997
  * Normal interactive flows should let `scheduleRender` do its job.
4842
5998
  */
4843
5999
  forceRender() {
6000
+ if (this.disposed)
6001
+ return;
4844
6002
  if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
4845
6003
  cancelAnimationFrame(this.renderRafId);
4846
6004
  this.renderRafId = null;
@@ -5014,10 +6172,17 @@ export class Editor {
5014
6172
  peerSelections: this._peerSelections,
5015
6173
  debugHitZones: this.debugHitZones,
5016
6174
  readOnly: this._readOnly,
6175
+ hoveredStickyId: this._hoveredStickyId,
6176
+ linkDrawOverrides: this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined,
5017
6177
  groupMoveOrigin: this.groupMoveOrigin,
5018
6178
  aspectLocked: this.selectionIsAspectLocked(),
5019
6179
  combinedSelectionBounds: this.combinedSelectionBounds(),
5020
6180
  editingText: this.editingTextOverlay(),
6181
+ snapGuides: this.interaction.snapGuides,
6182
+ sizeReadout: this.interaction.sizeReadout,
6183
+ sizeMatch: this.interaction.sizeMatch,
6184
+ showDistances: this._preferences.showObjectSize,
6185
+ isElementManipulable: (s) => this.isElementManipulable(s),
5021
6186
  previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
5022
6187
  isPlaybackPaused: (id) => this.isPlaybackPaused(id),
5023
6188
  };
@@ -5038,6 +6203,7 @@ export class Editor {
5038
6203
  // and, on the tile-cache path, clear the consumed dirty set.
5039
6204
  this.lastRenderedScene = this._scene;
5040
6205
  this.lastRenderedEnteredGroup = this._enteredGroup;
6206
+ this.lastRenderedHoveredSticky = this._hoveredStickyId;
5041
6207
  this.lastRenderedEraseActive = snapshot.eraseActive;
5042
6208
  // The forced erase repaint (if any) has now happened — later idle frames
5043
6209
  // (cursor moving / trail fading) skip the full main pass until the next cut.