@oh-just-another/state 0.62.0 → 0.64.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 +83 -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 +320 -20
  126. package/dist/editor.d.ts.map +1 -1
  127. package/dist/editor.js +1324 -73
  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,654 @@ 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
+ * Text-carrier routing for mixed selections: merge a partial text style
2722
+ * into the element style of text elements and into the label style of
2723
+ * labelable shapes (seeding the label when missing); other elements are
2724
+ * skipped. One undo step. The selection toolbar writes through this so a
2725
+ * shape + text selection shares one text-colour / decoration control
2726
+ * instead of choosing between `updateStyle` and `updateLabelStyle`.
2727
+ */
2728
+ updateTextStyle(ids, partial) {
2729
+ if (this.readOnly)
2730
+ return;
2731
+ const tx = this._history.transaction();
2732
+ let changed = false;
2733
+ for (const id of ids) {
2734
+ const shape = getElement(this._scene, id);
2735
+ if (shape === undefined)
2736
+ continue;
2737
+ let r;
2738
+ if (isText(shape)) {
2739
+ r = updateElement(this._scene, id, (raw) => ({
2740
+ ...raw,
2741
+ style: { ...raw.style, ...partial },
2742
+ }));
2743
+ }
2744
+ else if (withLabel(shape).label !== undefined) {
2745
+ r = updateElement(this._scene, id, (raw) => {
2746
+ const l = withLabel(raw);
2747
+ return l.label === undefined
2748
+ ? l
2749
+ : { ...l, label: { ...l.label, style: { ...l.label.style, ...partial } } };
2750
+ });
2751
+ }
2752
+ else {
2753
+ continue;
2754
+ }
2755
+ this._scene = r.scene;
2756
+ tx.add(r.patch);
2757
+ changed = true;
2758
+ }
2759
+ if (!changed)
2760
+ return;
2761
+ tx.commit();
2762
+ this.notify();
2763
+ }
2764
+ /**
2765
+ * Merge font family / size into the embedded label. Picking an
2766
+ * explicit `fontSize` also leaves auto-fit mode (reference behaviour —
2767
+ * a concrete size wins over the automatic one). One undo step.
2768
+ */
2769
+ updateLabelProps(ids, partial) {
2770
+ if (this.readOnly)
2771
+ return;
2772
+ const tx = this._history.transaction();
2773
+ let changed = false;
2774
+ for (const id of ids) {
2775
+ const shape = getElement(this._scene, id);
2776
+ if (shape === undefined || withLabel(shape).label === undefined)
2777
+ continue;
2778
+ const r = updateElement(this._scene, id, (raw) => {
2779
+ const s = withLabel(raw);
2780
+ if (s.label === undefined)
2781
+ return s;
2782
+ const label = { ...s.label, ...partial };
2783
+ if (partial.fontSize !== undefined)
2784
+ delete label.autoFit;
2785
+ return { ...s, label };
2786
+ });
2787
+ this._scene = r.scene;
2788
+ tx.add(r.patch);
2789
+ changed = true;
2790
+ }
2791
+ if (!changed)
2792
+ return;
2793
+ tx.commit();
2794
+ this.notify();
2795
+ }
2796
+ /** Toggle auto-fit label sizing (sticky notes). One undo step. */
2797
+ setLabelAutoFit(ids, on) {
2798
+ if (this.readOnly)
2799
+ return;
2800
+ const tx = this._history.transaction();
2801
+ let changed = false;
2802
+ for (const id of ids) {
2803
+ const shape = getElement(this._scene, id);
2804
+ if (shape === undefined || withLabel(shape).label === undefined)
2805
+ continue;
2806
+ const r = updateElement(this._scene, id, (raw) => {
2807
+ const s = withLabel(raw);
2808
+ if (s.label === undefined)
2809
+ return s;
2810
+ const label = { ...s.label };
2811
+ if (on)
2812
+ label.autoFit = true;
2813
+ else
2814
+ delete label.autoFit;
2815
+ return { ...s, label };
2816
+ });
2817
+ this._scene = r.scene;
2818
+ tx.add(r.patch);
2819
+ changed = true;
2820
+ }
2821
+ if (!changed)
2822
+ return;
2823
+ tx.commit();
2824
+ this.notify();
2825
+ }
2826
+ /**
2827
+ * Resize sticky notes to a square preset side (`STICKY_SIZE_PRESETS`).
2828
+ * One undo step; non-sticky ids are skipped.
2829
+ */
2830
+ setStickySize(ids, side) {
2831
+ if (this.readOnly)
2832
+ return;
2833
+ const tx = this._history.transaction();
2834
+ let changed = false;
2835
+ for (const id of ids) {
2836
+ const shape = getElement(this._scene, id);
2837
+ if (!shape || !isSticky(shape))
2838
+ continue;
2839
+ const r = updateElement(this._scene, id, (s) => ({ ...s, width: side, height: side }));
2840
+ this._scene = r.scene;
2841
+ tx.add(r.patch);
2842
+ changed = true;
2843
+ }
2844
+ if (!changed)
2845
+ return;
2846
+ tx.commit();
2847
+ this.notify();
2848
+ }
2849
+ /**
2850
+ * Toggle the author-name strip on sticky notes. Turning it on fills a
2851
+ * missing `authorName` from the editor's comment author (the local
2852
+ * user). One undo step.
2853
+ */
2854
+ toggleStickyAuthor(ids) {
2855
+ if (this.readOnly)
2856
+ return;
2857
+ const tx = this._history.transaction();
2858
+ let changed = false;
2859
+ for (const id of ids) {
2860
+ const shape = getElement(this._scene, id);
2861
+ if (!shape || !isSticky(shape))
2862
+ continue;
2863
+ const r = updateElement(this._scene, id, (s) => {
2864
+ const sticky = s;
2865
+ const showAuthor = sticky.showAuthor !== true;
2866
+ return {
2867
+ ...s,
2868
+ showAuthor,
2869
+ authorName: sticky.authorName ?? this.commentAuthor.name,
2870
+ };
2871
+ });
2872
+ this._scene = r.scene;
2873
+ tx.add(r.patch);
2874
+ changed = true;
2875
+ }
2876
+ if (!changed)
2877
+ return;
2878
+ tx.commit();
2879
+ this.notify();
2880
+ }
2881
+ /** Replace the tag list on sticky notes. One undo step. */
2882
+ setStickyTags(ids, tags) {
2883
+ if (this.readOnly)
2884
+ return;
2885
+ const tx = this._history.transaction();
2886
+ let changed = false;
2887
+ for (const id of ids) {
2888
+ const shape = getElement(this._scene, id);
2889
+ if (!shape || !isSticky(shape))
2890
+ continue;
2891
+ const r = updateElement(this._scene, id, (s) => {
2892
+ const copy = { ...s };
2893
+ if (tags.length === 0)
2894
+ delete copy.tags;
2895
+ else
2896
+ copy.tags = tags;
2897
+ return copy;
2898
+ });
2899
+ this._scene = r.scene;
2900
+ tx.add(r.patch);
2901
+ changed = true;
2902
+ }
2903
+ if (!changed)
2904
+ return;
2905
+ tx.commit();
2906
+ this.notify();
2907
+ }
2908
+ /**
2909
+ * Toggle the local user's emoji reaction on a sticky note. A glyph the
2910
+ * user has NOT reacted with gains their reaction (+1); one they
2911
+ * already reacted with loses it (-1, removing the glyph at zero).
2912
+ * Other users' reactions are never touched — so counters only grow
2913
+ * through OTHER collaborators. Plain element data → syncs through the
2914
+ * normal scene channel. One undo step.
2915
+ */
2916
+ toggleStickyReaction(id, glyph) {
2917
+ if (this.readOnly)
2918
+ return;
2919
+ const shape = getElement(this._scene, id);
2920
+ if (!shape || !isSticky(shape))
2921
+ return;
2922
+ const user = this.commentAuthor.id;
2923
+ const r = updateElement(this._scene, id, (s) => {
2924
+ const sticky = s;
2925
+ const reactions = [...(sticky.reactions ?? [])];
2926
+ const i = reactions.findIndex((x) => x.glyph === glyph);
2927
+ const existing = i >= 0 ? reactions[i] : undefined;
2928
+ // Pre-per-user scenes may carry `{glyph, count}` without `users` —
2929
+ // treat those as nobody-having-reacted and rewrite on first toggle.
2930
+ const existingUsers = existing?.users ?? [];
2931
+ if (existing === undefined) {
2932
+ reactions.push({ glyph, users: [user] });
2933
+ }
2934
+ else if (existingUsers.includes(user)) {
2935
+ const users = existingUsers.filter((u) => u !== user);
2936
+ if (users.length === 0)
2937
+ reactions.splice(i, 1);
2938
+ else
2939
+ reactions[i] = { glyph, users };
2940
+ }
2941
+ else {
2942
+ reactions[i] = { glyph, users: [...existingUsers, user] };
2943
+ }
2944
+ const copy = { ...s };
2945
+ if (reactions.length === 0)
2946
+ delete copy.reactions;
2947
+ else
2948
+ copy.reactions = reactions;
2949
+ return copy;
2950
+ });
2951
+ this._scene = r.scene;
2952
+ this._history.push(r.patch);
2953
+ this.notify();
2954
+ }
2955
+ /** Replace an emoji element's glyph (the toolbar picker). One undo step. */
2956
+ setEmojiGlyph(ids, glyph) {
2957
+ if (this.readOnly)
2958
+ return;
2959
+ const tx = this._history.transaction();
2960
+ let changed = false;
2961
+ for (const id of ids) {
2962
+ const shape = getElement(this._scene, id);
2963
+ if (!shape || !isEmoji(shape))
2964
+ continue;
2965
+ const r = updateElement(this._scene, id, (s) => ({ ...s, glyph }));
2966
+ this._scene = r.scene;
2967
+ tx.add(r.patch);
2968
+ changed = true;
2969
+ }
2970
+ if (!changed)
2971
+ return;
2972
+ tx.commit();
2973
+ this.notify();
2974
+ }
2975
+ /**
2976
+ * Resize a frame to one of the `FRAME_SIZE_PRESETS` (top-left corner
2977
+ * stays put; members keep their absolute positions). One undo step.
2978
+ */
2979
+ applyFramePreset(id, preset) {
2980
+ if (this.readOnly)
2981
+ return;
2982
+ const shape = getElement(this._scene, id);
2983
+ if (!shape || !isFrame(shape))
2984
+ return;
2985
+ const r = updateElement(this._scene, id, (s) => ({
2986
+ ...s,
2987
+ width: preset.width,
2988
+ height: preset.height,
2989
+ }));
2990
+ this._scene = r.scene;
2991
+ this._history.push(r.patch);
2992
+ this.notify();
2993
+ }
2994
+ /**
2995
+ * Rename the binary file behind an image (the toolbar's file-name
2996
+ * input). One undo step; no-op when the id is unknown or the name is
2997
+ * unchanged.
2998
+ */
2999
+ renameBinaryFile(id, name) {
3000
+ if (this.readOnly)
3001
+ return;
3002
+ const file = getBinaryFile(this._scene, id);
3003
+ if (!file || file.name === name)
3004
+ return;
3005
+ const patch = { kind: "file", id, before: file, after: { ...file, name } };
3006
+ this._scene = apply(this._scene, patch);
3007
+ this._history.push(patch);
3008
+ this.notify();
3009
+ }
3010
+ /** Set (or clear, with `null`) the accessible description on image shapes. */
3011
+ setImageAlt(ids, alt) {
3012
+ if (this.readOnly)
3013
+ return;
3014
+ const tx = this._history.transaction();
3015
+ let changed = false;
3016
+ for (const id of ids) {
3017
+ const shape = getElement(this._scene, id);
3018
+ if (!shape || !isImage(shape))
3019
+ continue;
3020
+ const r = updateElement(this._scene, id, (s) => {
3021
+ const copy = { ...s };
3022
+ if (alt === null || alt === "")
3023
+ delete copy.alt;
3024
+ else
3025
+ copy.alt = alt;
3026
+ return copy;
3027
+ });
3028
+ this._scene = r.scene;
3029
+ tx.add(r.patch);
3030
+ changed = true;
3031
+ }
3032
+ if (!changed)
3033
+ return;
3034
+ tx.commit();
3035
+ this.notify();
3036
+ }
3037
+ /**
3038
+ * Set (or clear, with `null`) the shape mask on image elements. The
3039
+ * mask clips the drawn box (renderer-side `RenderTarget.clip`);
3040
+ * normalised coordinates make it survive resizes. One undo step.
3041
+ */
3042
+ setImageMask(ids, mask) {
3043
+ if (this.readOnly)
3044
+ return;
3045
+ const tx = this._history.transaction();
3046
+ let changed = false;
3047
+ for (const id of ids) {
3048
+ const shape = getElement(this._scene, id);
3049
+ if (!shape || !isImage(shape))
3050
+ continue;
3051
+ const r = updateElement(this._scene, id, (s) => {
3052
+ const copy = { ...s };
3053
+ if (mask === null)
3054
+ delete copy.mask;
3055
+ else
3056
+ copy.mask = mask;
3057
+ return copy;
3058
+ });
3059
+ this._scene = r.scene;
3060
+ tx.add(r.patch);
3061
+ changed = true;
3062
+ }
3063
+ if (!changed)
3064
+ return;
3065
+ tx.commit();
3066
+ this.notify();
3067
+ }
3068
+ /**
3069
+ * Apply an aspect preset from the mask picker to image elements.
3070
+ * `"original"` clears crop AND mask and refits the box to the source's
3071
+ * natural aspect; the ratio presets centre-crop the source to the
3072
+ * target aspect and refit the box height (width kept), with `"circle"`
3073
+ * additionally installing an ellipse mask on the square box. The
3074
+ * source's natural aspect derives from the current box + crop (the box
3075
+ * always displays the crop region undistorted), so no live image
3076
+ * handle is needed. One undo step.
3077
+ */
3078
+ setImageAspectPreset(ids, preset) {
3079
+ if (this.readOnly)
3080
+ return;
3081
+ const tx = this._history.transaction();
3082
+ let changed = false;
3083
+ for (const id of ids) {
3084
+ const shape = getElement(this._scene, id);
3085
+ if (!shape || !isImage(shape))
3086
+ continue;
3087
+ const crop = shape.crop ?? { x: 0, y: 0, width: 1, height: 1 };
3088
+ const naturalAspect = shape.height > 0 && crop.width > 0
3089
+ ? (shape.width / shape.height) * (crop.height / crop.width)
3090
+ : 1;
3091
+ const r = updateElement(this._scene, id, (s) => {
3092
+ const copy = { ...s };
3093
+ if (preset === "original") {
3094
+ delete copy.crop;
3095
+ delete copy.mask;
3096
+ if (naturalAspect > 0)
3097
+ copy.height = copy.width / naturalAspect;
3098
+ return copy;
3099
+ }
3100
+ const aspect = IMAGE_ASPECT_PRESETS[preset];
3101
+ // Centred source crop with the target aspect: trim the longer axis.
3102
+ if (naturalAspect > aspect) {
3103
+ const w = aspect / naturalAspect;
3104
+ copy.crop = { x: (1 - w) / 2, y: 0, width: w, height: 1 };
3105
+ }
3106
+ else {
3107
+ const h = naturalAspect / aspect;
3108
+ copy.crop = { x: 0, y: (1 - h) / 2, width: 1, height: h };
3109
+ }
3110
+ copy.height = copy.width / aspect;
3111
+ if (preset === "circle")
3112
+ copy.mask = { kind: "ellipse" };
3113
+ else
3114
+ delete copy.mask;
3115
+ return copy;
3116
+ });
3117
+ this._scene = r.scene;
3118
+ tx.add(r.patch);
3119
+ changed = true;
3120
+ }
3121
+ if (!changed)
3122
+ return;
3123
+ tx.commit();
3124
+ this.notify();
3125
+ }
3126
+ /**
3127
+ * Swap a media shape's backing file for `blob` while keeping its
3128
+ * position and width (height refits to the new aspect). Accepts static
3129
+ * images, GIFs and videos — the shape's animation fields are rewritten
3130
+ * to match the NEW media kind, and `crop` resets when the media kind
3131
+ * changes (its pixel rect targeted the old content). Registers the new
3132
+ * `BinaryFile`, points the shape at it, drops the stale live handle
3133
+ * (`metadata.image`) and rebuilds handles for the new bytes. One undo
3134
+ * step.
3135
+ */
3136
+ async replaceImageFile(id, blob, name) {
3137
+ if (this.readOnly)
3138
+ return;
3139
+ const shape = getElement(this._scene, id);
3140
+ if (!shape || !isImage(shape))
3141
+ return;
3142
+ const added = await computeAddBinaryFile(this._scene, blob, name, () => ++this.nextId);
3143
+ const mime = blob.type;
3144
+ const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(name ?? "");
3145
+ const isGif = !isVideo && (mime === "image/gif" || /\.gif$/i.test(name ?? ""));
3146
+ const url = typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
3147
+ ? URL.createObjectURL(blob)
3148
+ : null;
3149
+ // The new media's aspect ratio replaces the old one: keep the shape's
3150
+ // width, refit the height — otherwise a differently-proportioned file
3151
+ // renders stretched into the old box. Videos measure through a hidden
3152
+ // looping <video> (which then stays on as the live handle, same as the
3153
+ // video drop handler); images/GIFs through createImageBitmap.
3154
+ let refitHeight = null;
3155
+ let videoHandle = null;
3156
+ if (isVideo) {
3157
+ if (typeof document !== "undefined" && url !== null) {
3158
+ const video = createHiddenLoopingVideo(url);
3159
+ await new Promise((resolve) => {
3160
+ video.onloadedmetadata = () => {
3161
+ resolve();
3162
+ };
3163
+ video.onerror = () => {
3164
+ resolve();
3165
+ };
3166
+ });
3167
+ if (video.videoWidth > 0) {
3168
+ refitHeight = (shape.width * video.videoHeight) / video.videoWidth;
3169
+ }
3170
+ // Best-effort play (muted autoplay can still need a prior gesture).
3171
+ void video.play().catch(() => {
3172
+ /* intentional no-op */
3173
+ });
3174
+ videoHandle = video;
3175
+ }
3176
+ }
3177
+ else if (typeof createImageBitmap === "function") {
3178
+ try {
3179
+ const bitmap = await createImageBitmap(blob);
3180
+ if (bitmap.width > 0) {
3181
+ refitHeight = (shape.width * bitmap.height) / bitmap.width;
3182
+ }
3183
+ bitmap.close();
3184
+ }
3185
+ catch {
3186
+ /* undecodable here — keep the old box; rehydration reports errors */
3187
+ }
3188
+ }
3189
+ // GIF frames decode from the raw bytes via the "gif" adapter.
3190
+ const animationBytes = isGif ? await blob.arrayBuffer() : undefined;
3191
+ const oldKind = shape.animationKind
3192
+ ? "gif"
3193
+ : shape.metadata?.animated === true
3194
+ ? "video"
3195
+ : "static";
3196
+ const newKind = isGif ? "gif" : isVideo ? "video" : "static";
3197
+ const tx = this._history.transaction();
3198
+ tx.add(added.patch);
3199
+ const r = updateElement(added.scene, id, (s) => {
3200
+ const copy = { ...s };
3201
+ copy.fileId = added.id;
3202
+ if (url !== null)
3203
+ copy.src = url;
3204
+ if (refitHeight !== null)
3205
+ copy.height = refitHeight;
3206
+ const meta = { ...copy.metadata };
3207
+ delete meta.image;
3208
+ delete meta.animated;
3209
+ if (newKind !== "static")
3210
+ meta.animated = true;
3211
+ if (videoHandle)
3212
+ meta.image = videoHandle;
3213
+ if (Object.keys(meta).length > 0)
3214
+ copy.metadata = meta;
3215
+ else
3216
+ delete copy.metadata;
3217
+ delete copy.animationKind;
3218
+ delete copy.animationData;
3219
+ if (isGif) {
3220
+ copy.animationKind = "gif";
3221
+ copy.animationData = animationBytes;
3222
+ }
3223
+ // The crop rect addressed the OLD media's pixels — meaningless
3224
+ // across a media-kind switch.
3225
+ if (oldKind !== newKind)
3226
+ delete copy.crop;
3227
+ return copy;
3228
+ });
3229
+ this._scene = r.scene;
3230
+ tx.add(r.patch);
3231
+ tx.commit();
3232
+ if (newKind !== "static") {
3233
+ this.gifPlayback.ensure(id);
3234
+ this.maybeAnimate();
3235
+ }
3236
+ this.notify();
3237
+ if (newKind === "static")
3238
+ await animScene.rehydrateStaticImages(this);
3239
+ }
3240
+ /**
3241
+ * Paragraph targets for the list operations: while inline-editing one
3242
+ * of `ids`, only the paragraphs covered by the caret / selection;
3243
+ * otherwise every paragraph of each text element. Returns `null` for
3244
+ * non-text ids.
3245
+ */
3246
+ paragraphTargets(id) {
3247
+ const shape = getElement(this._scene, id);
3248
+ if (!shape || !isText(shape))
3249
+ return null;
3250
+ const sel = this.editingTextElement === id ? this.editingTextSelection : null;
3251
+ if (sel)
3252
+ return paragraphRangeForOffsets(shape.text, sel.start, sel.end);
3253
+ return { first: 0, last: paragraphCount(shape.text) - 1 };
3254
+ }
3255
+ /**
3256
+ * Set (or clear, with `null`) the list kind on the targeted paragraphs
3257
+ * of every text element in `ids` — the inline-edit selection's
3258
+ * paragraphs while editing, the whole element otherwise. One undo step.
3259
+ */
3260
+ setParagraphList(ids, kind) {
3261
+ if (this.readOnly)
3262
+ return;
3263
+ const tx = this._history.transaction();
3264
+ let changed = false;
3265
+ for (const id of ids) {
3266
+ const range = this.paragraphTargets(id);
3267
+ if (!range)
3268
+ continue;
3269
+ const r = updateElement(this._scene, id, (s) => {
3270
+ const shape = s;
3271
+ const count = paragraphCount(shape.text);
3272
+ const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
3273
+ if (i < range.first || i > range.last)
3274
+ return p;
3275
+ const { list: _list, ...rest } = p;
3276
+ return kind === null ? rest : { ...rest, list: kind };
3277
+ });
3278
+ const normalized = normalizeParagraphs(next);
3279
+ const copy = { ...s };
3280
+ if (normalized === undefined)
3281
+ delete copy.paragraphs;
3282
+ else
3283
+ copy.paragraphs = normalized;
3284
+ return copy;
3285
+ });
3286
+ this._scene = r.scene;
3287
+ tx.add(r.patch);
3288
+ changed = true;
3289
+ }
3290
+ if (!changed)
3291
+ return;
3292
+ tx.commit();
3293
+ this.notify();
3294
+ }
3295
+ /**
3296
+ * Shift the targeted paragraphs' list nesting by `delta` (±1), clamped
3297
+ * to `[0, MAX_LIST_INDENT]`. Same targeting rules as
3298
+ * {@link setParagraphList}. One undo step.
3299
+ */
3300
+ indentParagraphs(ids, delta) {
3301
+ if (this.readOnly)
3302
+ return;
3303
+ const tx = this._history.transaction();
3304
+ let changed = false;
3305
+ for (const id of ids) {
3306
+ const range = this.paragraphTargets(id);
3307
+ if (!range)
3308
+ continue;
3309
+ const r = updateElement(this._scene, id, (s) => {
3310
+ const shape = s;
3311
+ const count = paragraphCount(shape.text);
3312
+ const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
3313
+ if (i < range.first || i > range.last)
3314
+ return p;
3315
+ const level = Math.max(0, Math.min(MAX_LIST_INDENT, (p.indent ?? 0) + delta));
3316
+ const { indent: _indent, ...rest } = p;
3317
+ return level === 0 ? rest : { ...rest, indent: level };
3318
+ });
3319
+ const normalized = normalizeParagraphs(next);
3320
+ const copy = { ...s };
3321
+ if (normalized === undefined)
3322
+ delete copy.paragraphs;
3323
+ else
3324
+ copy.paragraphs = normalized;
3325
+ return copy;
3326
+ });
3327
+ this._scene = r.scene;
3328
+ tx.add(r.patch);
3329
+ changed = true;
3330
+ }
3331
+ if (!changed)
3332
+ return;
3333
+ tx.commit();
3334
+ this.notify();
3335
+ }
2456
3336
  // --- F8: Eyedropper ---------------------------------------------------
2457
3337
  /**
2458
3338
  * The fill (or stroke, per `role`) colour of the top-most shape under the
@@ -2878,17 +3758,58 @@ export class Editor {
2878
3758
  }
2879
3759
  /**
2880
3760
  * Update non-style text properties (`fontSize`, `fontFamily`,
2881
- * `maxWidth`) on every selected text shape. Non-text shapes are
2882
- * skipped. Single undo step. Used by the text contextual panel.
3761
+ * `maxWidth`) on every text shape in `ids`; labelable shapes route
3762
+ * `fontSize` / `fontFamily` to their embedded label instead (as
3763
+ * `updateLabelProps` — an explicit size leaves auto-fit). Anything else is
3764
+ * skipped. Single undo step. Used by the selection toolbar, so a mixed
3765
+ * text + shape selection shares one font control.
2883
3766
  */
2884
3767
  updateTextProps(ids, partial) {
2885
3768
  if (this.readOnly)
2886
3769
  return;
2887
- const result = computeUpdateTextProps(this._scene, ids, partial);
2888
- if (!result)
3770
+ const textIds = [];
3771
+ const labelIds = [];
3772
+ for (const id of ids) {
3773
+ const el = getElement(this._scene, id);
3774
+ if (el === undefined)
3775
+ continue;
3776
+ if (isText(el))
3777
+ textIds.push(id);
3778
+ else if (withLabel(el).label !== undefined)
3779
+ labelIds.push(id);
3780
+ }
3781
+ const tx = this._history.transaction();
3782
+ let changed = false;
3783
+ const text = computeUpdateTextProps(this._scene, textIds, partial);
3784
+ if (text) {
3785
+ this._scene = text.scene;
3786
+ tx.add(text.patch);
3787
+ changed = true;
3788
+ }
3789
+ const { fontFamily, fontSize } = partial;
3790
+ if (fontFamily !== undefined || fontSize !== undefined) {
3791
+ const labelPartial = {
3792
+ ...(fontFamily !== undefined ? { fontFamily } : {}),
3793
+ ...(fontSize !== undefined ? { fontSize } : {}),
3794
+ };
3795
+ for (const id of labelIds) {
3796
+ const r = updateElement(this._scene, id, (raw) => {
3797
+ const l = withLabel(raw);
3798
+ if (l.label === undefined)
3799
+ return l;
3800
+ const label = { ...l.label, ...labelPartial };
3801
+ if (fontSize !== undefined)
3802
+ delete label.autoFit;
3803
+ return { ...l, label };
3804
+ });
3805
+ this._scene = r.scene;
3806
+ tx.add(r.patch);
3807
+ changed = true;
3808
+ }
3809
+ }
3810
+ if (!changed)
2889
3811
  return;
2890
- this._scene = result.scene;
2891
- this._history.push(result.patch);
3812
+ tx.commit();
2892
3813
  this.notify();
2893
3814
  }
2894
3815
  /**
@@ -2946,8 +3867,8 @@ export class Editor {
2946
3867
  * with its world bounds — for the hover link-popup. `null` when none.
2947
3868
  */
2948
3869
  linkAt(worldPoint) {
2949
- const shape = this.acceleratedElementAt(worldPoint);
2950
- if (!shape || !this.isElementInteractable(shape))
3870
+ const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
3871
+ if (!shape)
2951
3872
  return null;
2952
3873
  const href = safeHref(shape.href);
2953
3874
  if (!href)
@@ -3026,20 +3947,37 @@ export class Editor {
3026
3947
  * rotate gesture drives the same maths from a press-time snapshot.
3027
3948
  */
3028
3949
  rotateSelection(delta) {
3029
- const elements = [];
3950
+ // Groups carry their subtree: every descendant orbits the shared pivot.
3030
3951
  const origin = new Map();
3031
- for (const id of this._selection) {
3952
+ for (const id of this.expandSelectionWithDescendants()) {
3032
3953
  const el = getElement(this._scene, id);
3033
- if (el === undefined)
3954
+ if (el === undefined || isGroup(el))
3034
3955
  continue;
3035
- elements.push(el);
3036
3956
  origin.set(id, { position: el.position, rotation: el.rotation });
3037
3957
  }
3038
- if (elements.length === 0)
3958
+ const box = this.combinedSelectionBounds();
3959
+ if (origin.size === 0 || !box)
3039
3960
  return;
3040
- const pivot = selectionCenter(elements);
3961
+ const pivot = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
3041
3962
  this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
3042
3963
  }
3964
+ /**
3965
+ * Straighten the selection: every selected element (and descendants of
3966
+ * selected groups) goes back to `rotation: 0`, each turning about its own
3967
+ * centre so it stays where it is. One undoable step; no-op when nothing
3968
+ * is rotated. Triggered by a double-click on the rotate grip.
3969
+ */
3970
+ resetSelectionRotation() {
3971
+ const patches = [];
3972
+ for (const id of this.expandSelectionWithDescendants()) {
3973
+ const el = getElement(this._scene, id);
3974
+ if (el === undefined || el.rotation === 0)
3975
+ continue;
3976
+ const origin = new Map([[id, { position: el.position, rotation: el.rotation }]]);
3977
+ patches.push(...computeRotatePatches(this._scene, origin, selectionCenter([el]), -el.rotation));
3978
+ }
3979
+ this.commitArrange(patches);
3980
+ }
3043
3981
  /** Apply a batch of arrange patches as a single undoable step. */
3044
3982
  commitArrange(patches) {
3045
3983
  if (this.readOnly)
@@ -3191,6 +4129,18 @@ export class Editor {
3191
4129
  const center = this.screenToWorld({ x: vp.size.width / 2, y: vp.size.height / 2 });
3192
4130
  this.zoomAt(factor, center);
3193
4131
  }
4132
+ /**
4133
+ * Set an absolute zoom level (1 = 100%), scaling about the viewport
4134
+ * centre and clamped to `MIN_ZOOM..MAX_ZOOM`. The zoom menu's presets.
4135
+ */
4136
+ setZoom(level) {
4137
+ const vp = this._scene.viewport;
4138
+ const current = vp.zoom || 1;
4139
+ const target = clampZoom(level);
4140
+ if (target === current || vp.size.width <= 0 || vp.size.height <= 0)
4141
+ return;
4142
+ this.zoomStep(target / current);
4143
+ }
3194
4144
  resetZoom() {
3195
4145
  const next = computeResetZoom(this._scene);
3196
4146
  if (!next)
@@ -3232,6 +4182,20 @@ export class Editor {
3232
4182
  this._scene = next;
3233
4183
  this.notify();
3234
4184
  }
4185
+ /**
4186
+ * "Back to content": jump to the element nearest the camera centre, keeping
4187
+ * the current zoom (it only drops when that element doesn't fit). Unlike
4188
+ * {@link zoomToFit} this neither shrinks a large board to a speck nor blows
4189
+ * a single small shape up to full screen. Groups are skipped (their own
4190
+ * bounds are empty — their children count). No-op on an empty scene.
4191
+ */
4192
+ revealNearestContent(padding = 80) {
4193
+ const next = computeRevealNearest(this._scene, padding, (s) => s.type !== "group");
4194
+ if (!next)
4195
+ return;
4196
+ this._scene = next;
4197
+ this.notify();
4198
+ }
3235
4199
  /**
3236
4200
  * Select the nearest interactable top-level element in `direction` from the
3237
4201
  * current selection's centre (or the viewport centre when nothing is
@@ -3314,6 +4278,137 @@ export class Editor {
3314
4278
  snapSpacing() {
3315
4279
  return resolveSnapSpacing();
3316
4280
  }
4281
+ /**
4282
+ * Object snapping is a per-user preference; the suppress modifier
4283
+ * (Cmd/Ctrl) bypasses it for the current gesture like grid snapping.
4284
+ */
4285
+ objectSnapActive() {
4286
+ return !this.interaction.snapSuppressed && this._preferences.snapObjects;
4287
+ }
4288
+ sizeSuggestActive() {
4289
+ return !this.interaction.snapSuppressed && this._preferences.suggestObjectSize;
4290
+ }
4291
+ /**
4292
+ * World AABBs of the shapes a gesture may snap to (reference rules): every
4293
+ * visible element on screen that is not being moved / resized, except
4294
+ * groups (no geometry of their own), brush strokes (freehand ink is never
4295
+ * a snap target), shapes rotated off the 90° grid, and shapes too small on
4296
+ * screen (`OBJECT_SNAP_MIN_SIZE_PX`). Returns nothing on a crowded view
4297
+ * (`OBJECT_SNAP_MAX_CANDIDATES`) so a huge board never stalls a drag.
4298
+ */
4299
+ snapCandidates(moving) {
4300
+ const view = this.computeViewportWorld();
4301
+ const zoom = this._scene.viewport.zoom || 1;
4302
+ const minSize = OBJECT_SNAP_MIN_SIZE_PX / zoom;
4303
+ const out = [];
4304
+ for (const shape of this._scene.elements.values()) {
4305
+ if (moving.has(shape.id) || isGroup(shape) || isBrush(shape))
4306
+ continue;
4307
+ if (!isRightAngle(shape.rotation))
4308
+ continue;
4309
+ if (isElementHidden(this._scene, shape))
4310
+ continue;
4311
+ const b = getElementWorldBounds(shape);
4312
+ if (view && !B.intersects(view, b))
4313
+ continue;
4314
+ if (b.width < minSize && b.height < minSize)
4315
+ continue;
4316
+ out.push(b);
4317
+ if (out.length > OBJECT_SNAP_MAX_CANDIDATES)
4318
+ return [];
4319
+ }
4320
+ return out;
4321
+ }
4322
+ /** Brush strokes never snap (as movers or targets) — freehand ink has no meaningful edges. */
4323
+ snapsAsMover(id) {
4324
+ const shape = getElement(this._scene, id);
4325
+ return shape !== undefined && !isBrush(shape);
4326
+ }
4327
+ /**
4328
+ * Move-delta snapping: object snapping (edges / centres of nearby shapes)
4329
+ * wins when it lands; otherwise grid snapping. Records the guides for the
4330
+ * overlay either way.
4331
+ */
4332
+ snapMoveDeltaFor(moving, bounds, delta) {
4333
+ this.interaction.snapGuides = [];
4334
+ if (this.objectSnapActive() && [...moving].some((id) => this.snapsAsMover(id))) {
4335
+ const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
4336
+ const r = snapMoveDeltaToObjects(bounds, delta, this.snapCandidates(moving), threshold);
4337
+ if (r.guides.length > 0) {
4338
+ this.interaction.snapGuides = r.guides;
4339
+ return r.delta;
4340
+ }
4341
+ }
4342
+ return this.snapActive() ? snapMoveDelta(bounds, delta, this.snapSpacing()) : delta;
4343
+ }
4344
+ /**
4345
+ * Group move: the press-time union AABB of the members (each member's
4346
+ * current geometry at its press-time position; brush strokes excluded)
4347
+ * snaps to nearby shapes by its frame EDGES only — a multi-selection has
4348
+ * no centre line, like the reference. Falls back to the grid
4349
+ * reference-point snap.
4350
+ */
4351
+ snapGroupMoveDelta(origins, delta) {
4352
+ this.interaction.snapGuides = [];
4353
+ if (this.objectSnapActive()) {
4354
+ let union = null;
4355
+ let members = 0;
4356
+ for (const [id, position] of origins) {
4357
+ const shape = getElement(this._scene, id);
4358
+ if (!shape || isGroup(shape) || isBrush(shape))
4359
+ continue;
4360
+ members += 1;
4361
+ const b = getElementWorldBounds({ ...shape, position });
4362
+ union = union ? B.union(union, b) : b;
4363
+ }
4364
+ if (union) {
4365
+ const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
4366
+ const r = snapMoveDeltaToObjects(union, delta, this.snapCandidates(new Set(origins.keys())), threshold, { centerLines: members === 1 });
4367
+ if (r.guides.length > 0) {
4368
+ this.interaction.snapGuides = r.guides;
4369
+ return r.delta;
4370
+ }
4371
+ }
4372
+ }
4373
+ return this.snapActive() ? snapGroupDelta(origins, delta, this.snapSpacing()) : delta;
4374
+ }
4375
+ /** Resize-delta counterpart of {@link snapMoveDeltaFor}; also sets `sizeMatch`. */
4376
+ snapResizeDeltaFor(moving, original, handle, delta) {
4377
+ this.interaction.snapGuides = [];
4378
+ this.interaction.sizeMatch = null;
4379
+ const alignEdges = this.objectSnapActive();
4380
+ const matchSizes = this.sizeSuggestActive();
4381
+ if ((alignEdges || matchSizes) && [...moving].some((id) => this.snapsAsMover(id))) {
4382
+ const zoom = this._scene.viewport.zoom || 1;
4383
+ const threshold = Math.max(OBJECT_SNAP_THRESHOLD_PX, SIZE_SUGGEST_THRESHOLD_PX) / zoom;
4384
+ const r = snapResizeDeltaToObjects(original, handle, delta, this.snapCandidates(moving), threshold, { alignEdges, matchSizes });
4385
+ if (r.guides.length > 0 || r.sizeMatch !== null) {
4386
+ this.interaction.snapGuides = r.guides;
4387
+ this.interaction.sizeMatch = r.sizeMatch;
4388
+ return r.delta;
4389
+ }
4390
+ }
4391
+ return this.snapActive() ? snapResizeDelta(original, handle, delta, this.snapSpacing()) : delta;
4392
+ }
4393
+ /** Publish the `W × H` readout for `bounds` when the preference is on. */
4394
+ setSizeReadout(bounds) {
4395
+ this.interaction.sizeReadout =
4396
+ bounds && this._preferences.showObjectSize
4397
+ ? { bounds, width: bounds.width, height: bounds.height }
4398
+ : null;
4399
+ }
4400
+ /** Alignment guides of the current gesture tick (object snapping). */
4401
+ get snapGuides() {
4402
+ return this.interaction.snapGuides;
4403
+ }
4404
+ /** `W × H` readout of the shape being resized, when `showObjectSize` is on. */
4405
+ get sizeReadout() {
4406
+ return this.interaction.sizeReadout;
4407
+ }
4408
+ /** Bounds of the shape whose size the current resize matched, if any. */
4409
+ get sizeMatch() {
4410
+ return this.interaction.sizeMatch;
4411
+ }
3317
4412
  /**
3318
4413
  * Replace the entire scene (e.g. after `parseScene`). Clears history,
3319
4414
  * selection and any open gesture. Use to load a saved document.
@@ -3323,7 +4418,9 @@ export class Editor {
3323
4418
  this.gestureTx.cancel();
3324
4419
  this.gestureTx = null;
3325
4420
  }
3326
- this._scene = scene;
4421
+ // A saved start view is where a freshly opened document lands; a
4422
+ // peer update (`preserveHistory`) must not yank the local camera.
4423
+ this._scene = options.preserveHistory ? scene : withStartViewApplied(scene);
3327
4424
  // Snap active layer back into the loaded scene's layer set.
3328
4425
  if (!scene.layers.has(this._activeLayerId)) {
3329
4426
  const first = scene.layers.keys().next().value;
@@ -3358,6 +4455,7 @@ export class Editor {
3358
4455
  }
3359
4456
  /** Detach all DOM listeners and stop the actor. */
3360
4457
  dispose() {
4458
+ this.disposed = true;
3361
4459
  this.cancelLongPress();
3362
4460
  this.unbind();
3363
4461
  this.actor.stop();
@@ -3410,10 +4508,10 @@ export class Editor {
3410
4508
  // Right-click without a drag → trigger the context-menu listeners.
3411
4509
  // Same payload as touch long-press so existing UI (e.g.
3412
4510
  // `@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
- }
4511
+ this.fireContextPress({
4512
+ screenPoint: gesture.startPoint,
4513
+ worldPoint: this.screenToWorld(gesture.startPoint),
4514
+ });
3417
4515
  }
3418
4516
  else {
3419
4517
  // Either it was a real drag, or Space + left drag. In both cases the
@@ -3424,9 +4522,48 @@ export class Editor {
3424
4522
  // Pan over — recompute (→ "grab" if Space/hand still armed, else the
3425
4523
  // idle hover cursor).
3426
4524
  this.refreshCursor();
4525
+ // The gesture's end is itself a change: idle-gated observers (the
4526
+ // minimap) skip every pan notify while `panGesture` is set and rely on
4527
+ // this trailing one to repaint.
4528
+ this.notify();
3427
4529
  }
3428
4530
  isDrawingPhase(ctx) {
3429
- return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
4531
+ return (ctx.mode === "draw-rect" ||
4532
+ ctx.mode === "draw-ellipse" ||
4533
+ ctx.mode === "draw-frame" ||
4534
+ ctx.mode === "draw-edge");
4535
+ }
4536
+ /**
4537
+ * Context press (right-click / touch long-press): first make the selection
4538
+ * match what was pressed, then notify the `onLongPress` listeners so the
4539
+ * menu they open describes that point.
4540
+ * - On an unselected element / link → it becomes the selection.
4541
+ * - On a selected element (or its chrome: handles, rotate grip) → keep.
4542
+ * - On empty canvas → clear elements + links, so the menu is the canvas
4543
+ * menu rather than the previous selection's.
4544
+ */
4545
+ fireContextPress(payload) {
4546
+ this.routeContextPress(payload.worldPoint);
4547
+ for (const fn of this.longPressListeners)
4548
+ fn(payload);
4549
+ }
4550
+ routeContextPress(worldPoint) {
4551
+ const target = this.hitTest(worldPoint);
4552
+ if (target.kind === "element") {
4553
+ if (!this._selection.has(target.id)) {
4554
+ this.applyEmit({ type: "SELECT_REPLACE", id: target.id });
4555
+ }
4556
+ }
4557
+ else if (target.kind === "link") {
4558
+ if (!this._selectedLinks.has(target.id)) {
4559
+ this.applyEmit({ type: "SELECT_EDGE_REPLACE", id: target.id });
4560
+ }
4561
+ }
4562
+ else if (target.kind === "empty") {
4563
+ if (this._selection.size > 0 || this._selectedLinks.size > 0) {
4564
+ this.applyEmit({ type: "SELECT_CLEAR" });
4565
+ }
4566
+ }
3430
4567
  }
3431
4568
  // --- Long-press ---
3432
4569
  startLongPress(screenPoint) {
@@ -3462,8 +4599,8 @@ export class Editor {
3462
4599
  * `undefined` → dropped on empty space (the end stays a free point).
3463
4600
  */
3464
4601
  linkAttachTargetAt(worldPoint) {
3465
- const shape = this.acceleratedElementAt(worldPoint);
3466
- if (shape && this.isElementInteractable(shape)) {
4602
+ const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
4603
+ if (shape) {
3467
4604
  const target = this.promoteToGroupRoot(shape);
3468
4605
  return { kind: "element", id: target.id, bounds: getElementWorldBounds(target) };
3469
4606
  }
@@ -3486,8 +4623,9 @@ export class Editor {
3486
4623
  hitAnnotation: (p) => this.hitAnnotation(p),
3487
4624
  selectionIsAspectLocked: () => this.selectionIsAspectLocked(),
3488
4625
  combinedSelectionBounds: () => this.combinedSelectionBounds(),
3489
- acceleratedElementAt: (p) => this.acceleratedElementAt(p),
4626
+ acceleratedElementAt: (p, accept) => this.acceleratedElementAt(p, accept),
3490
4627
  isElementInteractable: (s) => this.isElementInteractable(s),
4628
+ isElementManipulable: (s) => this.isElementManipulable(s),
3491
4629
  isLayerLocked: (id) => this.isLayerLocked(id),
3492
4630
  promoteToGroupRoot: (s) => this.promoteToGroupRoot(s),
3493
4631
  });
@@ -3542,10 +4680,12 @@ export class Editor {
3542
4680
  return false;
3543
4681
  if (isElementHidden(this._scene, shape))
3544
4682
  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.
4683
+ // A locked element is click-through: pointer hits pass to the shapes
4684
+ // beneath it (the hit-test scans past rejected candidates). Unlocking
4685
+ // goes through the context menu (`lockedElementAt` + `unlockElement`),
4686
+ // not through selection.
4687
+ if (isElementLocked(this._scene, shape))
4688
+ return false;
3549
4689
  return true;
3550
4690
  }
3551
4691
  /**
@@ -3590,8 +4730,56 @@ export class Editor {
3590
4730
  tx.add(r.patch);
3591
4731
  }
3592
4732
  tx.commit();
4733
+ // Locked elements are click-through (not selectable) — keeping them
4734
+ // selected would leave a live toolbar on an element clicks can no
4735
+ // longer reach. Locking therefore drops the selection.
4736
+ if (anyUnlocked)
4737
+ this._selection = Selection.EMPTY;
3593
4738
  this.notify();
3594
4739
  }
4740
+ /**
4741
+ * Topmost locked (own or inherited `locked`) shape at `worldPoint`, on a
4742
+ * visible, unlocked layer. Locked shapes are click-through for normal
4743
+ * interaction, so this is the dedicated lookup for affordances that must
4744
+ * still find them — the context menu's Unlock entry.
4745
+ */
4746
+ lockedElementAt(worldPoint) {
4747
+ const shape = this.acceleratedElementAt(worldPoint, (s) => !this.isLayerLocked(s.layerId) &&
4748
+ !isElementHidden(this._scene, s) &&
4749
+ isElementLocked(this._scene, s));
4750
+ return shape ?? null;
4751
+ }
4752
+ /**
4753
+ * Clear the `locked` flag on the shape that carries it. When the flag
4754
+ * lives on an ancestor (group lock propagation), the closest locked
4755
+ * ancestor is unlocked instead, so the whole locked unit is released.
4756
+ * Selects the unlocked shape afterwards — the natural next step after
4757
+ * an unlock is editing it. One undo step.
4758
+ */
4759
+ unlockElement(id) {
4760
+ if (this.readOnly)
4761
+ return;
4762
+ let target = getElement(this._scene, id);
4763
+ if (!target || !isElementLocked(this._scene, target))
4764
+ return;
4765
+ // Walk up to the shape that actually carries the flag.
4766
+ while (target.locked !== true && target.parentId !== undefined) {
4767
+ const parent = getElement(this._scene, target.parentId);
4768
+ if (!parent)
4769
+ break;
4770
+ target = parent;
4771
+ }
4772
+ if (target.locked !== true)
4773
+ return;
4774
+ const r = updateElement(this._scene, target.id, (s) => {
4775
+ const copy = { ...s };
4776
+ delete copy.locked;
4777
+ return copy;
4778
+ });
4779
+ this._scene = r.scene;
4780
+ this._history.push(r.patch);
4781
+ this.setSelection([target.id]);
4782
+ }
3595
4783
  /**
3596
4784
  * Promote a hit shape to the topmost ancestor whose group we have NOT
3597
4785
  * "entered" yet. Only **`group`**-typed parents promote — containers
@@ -3754,11 +4942,11 @@ export class Editor {
3754
4942
  * scene-identity. Scene operations replace `_scene` (immutable patches),
3755
4943
  * so reference-equality is a sufficient invalidation signal.
3756
4944
  */
3757
- acceleratedElementAt(worldPoint) {
4945
+ acceleratedElementAt(worldPoint, accept) {
3758
4946
  if (this._scene.elements.size < LARGE_SCENE_HIT_THRESHOLD) {
3759
- return getElementAt(this._scene, worldPoint);
4947
+ return getElementAt(this._scene, worldPoint, accept);
3760
4948
  }
3761
- return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint);
4949
+ return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint, accept);
3762
4950
  }
3763
4951
  /**
3764
4952
  * Build (or return the cached) `SpatialGrid` for the current scene.
@@ -3801,9 +4989,7 @@ export class Editor {
3801
4989
  */
3802
4990
  routeIsolationClick(clickEffect, worldPoint) {
3803
4991
  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;
4992
+ const isDouble = this.interaction.isDoubleClickAt(worldPoint, now);
3807
4993
  this.interaction.lastClickAt = now;
3808
4994
  this.interaction.lastClickWorldPoint = worldPoint;
3809
4995
  // Double-click the frame HEADER (label strip above the body) → rename.
@@ -3852,6 +5038,12 @@ export class Editor {
3852
5038
  const raw = this.acceleratedElementAt(worldPoint);
3853
5039
  if (raw !== undefined && isText(raw)) {
3854
5040
  this.beginTextEdit(raw.id);
5041
+ // Caret lands where the user double-clicked, not at the text end.
5042
+ // Collapsed selection WITHOUT a drag anchor — the double-click's
5043
+ // own pointer movement must not start a drag-select.
5044
+ const caretIdx = this.caretIndexAtWorldPoint(worldPoint);
5045
+ if (caretIdx !== null)
5046
+ this.setEditingSelection(caretIdx, caretIdx);
3855
5047
  return true;
3856
5048
  }
3857
5049
  if (raw !== undefined && isFrame(raw)) {
@@ -3871,6 +5063,18 @@ export class Editor {
3871
5063
  this.notify();
3872
5064
  return true;
3873
5065
  }
5066
+ // No group to drill into — double-click on a labelable shape
5067
+ // opens its embedded label editor (creating an empty label on
5068
+ // the first edit).
5069
+ if (canCarryLabel(raw)) {
5070
+ this.beginTextEdit(raw.id);
5071
+ // Same as text elements: the caret starts at the click point —
5072
+ // important for long labels whose tail is clipped by the shape.
5073
+ const labelCaretIdx = this.caretIndexAtWorldPoint(worldPoint);
5074
+ if (labelCaretIdx !== null)
5075
+ this.setEditingSelection(labelCaretIdx, labelCaretIdx);
5076
+ return true;
5077
+ }
3874
5078
  }
3875
5079
  }
3876
5080
  return false;
@@ -4066,7 +5270,9 @@ export class Editor {
4066
5270
  return;
4067
5271
  // Shift constrains the drag to one axis before snapping.
4068
5272
  const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
4069
- const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
5273
+ // Rotated shapes keep their AABB-based snapping (edges still align in
5274
+ // world space); the object pass is skipped only when nothing is nearby.
5275
+ const d = this.snapMoveDeltaFor(new Set([id]), originalBounds, moved);
4070
5276
  const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
4071
5277
  if (!patch)
4072
5278
  return;
@@ -4079,9 +5285,7 @@ export class Editor {
4079
5285
  return;
4080
5286
  // Shift constrains the drag to one axis before snapping.
4081
5287
  const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
4082
- const d = this.snapActive()
4083
- ? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
4084
- : moved;
5288
+ const d = this.snapGroupMoveDelta(this.groupMoveOrigin, moved);
4085
5289
  const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
4086
5290
  for (const patch of patches) {
4087
5291
  this._scene = apply(this._scene, patch);
@@ -4174,6 +5378,10 @@ export class Editor {
4174
5378
  // repaint when the entered-group identity changes between frames.
4175
5379
  if (this.lastRenderedEnteredGroup !== this._enteredGroup)
4176
5380
  return null;
5381
+ // Hover transition on a sticky toggles the "+" chrome without a scene
5382
+ // change — force a repaint frame for it.
5383
+ if (this.lastRenderedHoveredSticky !== this._hoveredStickyId)
5384
+ return null;
4177
5385
  // Eraser just STOPPED (the active-stroke guard above already returned, so
4178
5386
  // `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
4179
5387
  // without a scene change — commit changes the scene and is caught by the
@@ -4274,9 +5482,7 @@ export class Editor {
4274
5482
  applyGroupResize(handle, delta, originalBounds) {
4275
5483
  if (!this.groupResizeOrigin)
4276
5484
  return;
4277
- const d = this.snapActive()
4278
- ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
4279
- : delta;
5485
+ const d = this.snapResizeDeltaFor(new Set(this.groupResizeOrigin.elements.keys()), originalBounds, handle, delta);
4280
5486
  const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
4281
5487
  // Aspect-locked when the selection type demands it (images / groups) or
4282
5488
  // the user holds Shift for this gesture.
@@ -4284,6 +5490,7 @@ export class Editor {
4284
5490
  this._scene = result.scene;
4285
5491
  for (const patch of result.patches)
4286
5492
  this.recordGesturePatch(patch);
5493
+ this.setSizeReadout(this.combinedSelectionBounds());
4287
5494
  this.notify();
4288
5495
  }
4289
5496
  applyResize(id, handle, delta, originalBounds) {
@@ -4301,12 +5508,11 @@ export class Editor {
4301
5508
  return;
4302
5509
  this._scene = result.scene;
4303
5510
  this.recordGesturePatch(result.patch);
5511
+ this.setSizeReadout(this.resizedBounds(id));
4304
5512
  this.notify();
4305
5513
  return;
4306
5514
  }
4307
- const d = this.snapActive()
4308
- ? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
4309
- : delta;
5515
+ const d = this.snapResizeDeltaFor(new Set([id]), originalBounds, handle, delta);
4310
5516
  // Text: aspect-locked font scaling. Snapshot the pristine shape on
4311
5517
  // the gesture's first tick so the scale base never compounds.
4312
5518
  if (shape !== undefined && isText(shape)) {
@@ -4322,6 +5528,7 @@ export class Editor {
4322
5528
  return;
4323
5529
  this._scene = result.scene;
4324
5530
  this.recordGesturePatch(result.patch);
5531
+ this.setSizeReadout(this.resizedBounds(id));
4325
5532
  this.notify();
4326
5533
  return;
4327
5534
  }
@@ -4330,12 +5537,32 @@ export class Editor {
4330
5537
  return;
4331
5538
  this._scene = result.scene;
4332
5539
  this.recordGesturePatch(result.patch);
5540
+ this.setSizeReadout(this.resizedBounds(id));
4333
5541
  this.notify();
4334
5542
  }
5543
+ /** World AABB of `id` after a resize tick (null when the shape is gone). */
5544
+ resizedBounds(id) {
5545
+ const shape = getElement(this._scene, id);
5546
+ return shape ? getElementWorldBounds(shape) : null;
5547
+ }
5548
+ /**
5549
+ * Snap freshly-drawn bounds onto the grid when snapping is active.
5550
+ * Shared by the live rubber-band preview and the final CREATE so the
5551
+ * preview shows exactly where the shape will land (parity with how
5552
+ * move / resize snap live during the gesture).
5553
+ */
5554
+ snapCreateBoundsIfActive(bounds) {
5555
+ return this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
5556
+ }
4335
5557
  applyCreate(kind, bounds) {
4336
5558
  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());
5559
+ const b = this.snapCreateBoundsIfActive(bounds);
5560
+ // The machine only knows the MODE ("rect" for draw-rect); the flyout
5561
+ // may have armed a diamond / triangle variant on top of it.
5562
+ const effectiveKind = kind === "rect" && this._drawShapeKind !== "rect" && this._drawShapeKind !== "ellipse"
5563
+ ? this._drawShapeKind
5564
+ : kind;
5565
+ const result = computeCreateElement(this._scene, effectiveKind, b, id, this._activeLayerId, () => this.nextFrameName());
4339
5566
  this._scene = result.scene;
4340
5567
  this._selection = Selection.single(id);
4341
5568
  // CREATE is a single-shot operation, not part of a multi-tick gesture.
@@ -4376,7 +5603,7 @@ export class Editor {
4376
5603
  const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
4377
5604
  const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
4378
5605
  const id = newLinkId(++this.nextId);
4379
- const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId);
5606
+ const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId, this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined);
4380
5607
  this._scene = result.scene;
4381
5608
  this._history.push(result.patch);
4382
5609
  this.edgePreview = null;
@@ -4477,7 +5704,7 @@ export class Editor {
4477
5704
  // The wrappers here own the side effects (`_selectedLink`
4478
5705
  // clearing, notify).
4479
5706
  applySelectByBounds(bounds, mode) {
4480
- const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
5707
+ const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
4481
5708
  const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
4482
5709
  const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
4483
5710
  this._selectedLinks = nextLinks;
@@ -4490,7 +5717,7 @@ export class Editor {
4490
5717
  }
4491
5718
  applyLassoLiveSelection(bounds, mode) {
4492
5719
  const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
4493
- const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
5720
+ const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
4494
5721
  const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
4495
5722
  const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
4496
5723
  const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
@@ -4687,9 +5914,12 @@ export class Editor {
4687
5914
  }
4688
5915
  applyLinkPreview(fromElement, fromPoint, toPoint) {
4689
5916
  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") {
5917
+ // Match the preview to the connector that will be committed: elbow
5918
+ // presets/defaults draw the orthogonal route, straight presets a line.
5919
+ const previewRouting = this._linkDrawPreset !== null
5920
+ ? LINK_DRAW_PRESETS[this._linkDrawPreset].routing
5921
+ : DEFAULT_LINK_ROUTING;
5922
+ if (previewRouting === "orthogonal") {
4693
5923
  const hit = this.hitTest(ep.to);
4694
5924
  const toElement = hit.kind === "element" ? hit.id : null;
4695
5925
  const points = routeElbowPreview(this._scene, fromElement, ep.from, toElement, ep.to);
@@ -4713,6 +5943,7 @@ export class Editor {
4713
5943
  }
4714
5944
  commitGesture() {
4715
5945
  this.interaction.resizeOriginElement = null;
5946
+ this.interaction.clearSnapAssists();
4716
5947
  this.rotateGestureOrigin = null;
4717
5948
  this.gestures.commit();
4718
5949
  this.gestureStartScene = null;
@@ -4758,6 +5989,7 @@ export class Editor {
4758
5989
  }
4759
5990
  cancelGesture() {
4760
5991
  this.interaction.resizeOriginElement = null;
5992
+ this.interaction.clearSnapAssists();
4761
5993
  this.interaction.rotateGestureOrigin = null;
4762
5994
  this.gestures.cancel();
4763
5995
  // Roll the scene back to the pre-gesture snapshot — cancelling the history
@@ -4801,6 +6033,13 @@ export class Editor {
4801
6033
  * into a single render per frame.
4802
6034
  */
4803
6035
  renderRafId = null;
6036
+ /**
6037
+ * Set by {@link dispose}. Render scheduling bails out when set:
6038
+ * async completions (image decode, font load, wasm init) can resolve
6039
+ * after the host tears the editor down (e.g. a runtime backend
6040
+ * switch) and must not paint onto disposed render targets.
6041
+ */
6042
+ disposed = false;
4804
6043
  /** Unsubscribe for the animation-content-ready listener (decode → re-render). */
4805
6044
  animationContentOff = null;
4806
6045
  /**
@@ -4816,6 +6055,8 @@ export class Editor {
4816
6055
  * compare bitmap output after a mutation).
4817
6056
  */
4818
6057
  scheduleRender() {
6058
+ if (this.disposed)
6059
+ return; // async completions (image decode, font load) may outlive the editor
4819
6060
  if (this.renderRafId !== null)
4820
6061
  return;
4821
6062
  if (typeof requestAnimationFrame === "undefined") {
@@ -4841,6 +6082,8 @@ export class Editor {
4841
6082
  * Normal interactive flows should let `scheduleRender` do its job.
4842
6083
  */
4843
6084
  forceRender() {
6085
+ if (this.disposed)
6086
+ return;
4844
6087
  if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
4845
6088
  cancelAnimationFrame(this.renderRafId);
4846
6089
  this.renderRafId = null;
@@ -5014,10 +6257,17 @@ export class Editor {
5014
6257
  peerSelections: this._peerSelections,
5015
6258
  debugHitZones: this.debugHitZones,
5016
6259
  readOnly: this._readOnly,
6260
+ hoveredStickyId: this._hoveredStickyId,
6261
+ linkDrawOverrides: this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined,
5017
6262
  groupMoveOrigin: this.groupMoveOrigin,
5018
6263
  aspectLocked: this.selectionIsAspectLocked(),
5019
6264
  combinedSelectionBounds: this.combinedSelectionBounds(),
5020
6265
  editingText: this.editingTextOverlay(),
6266
+ snapGuides: this.interaction.snapGuides,
6267
+ sizeReadout: this.interaction.sizeReadout,
6268
+ sizeMatch: this.interaction.sizeMatch,
6269
+ showDistances: this._preferences.showObjectSize,
6270
+ isElementManipulable: (s) => this.isElementManipulable(s),
5021
6271
  previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
5022
6272
  isPlaybackPaused: (id) => this.isPlaybackPaused(id),
5023
6273
  };
@@ -5038,6 +6288,7 @@ export class Editor {
5038
6288
  // and, on the tile-cache path, clear the consumed dirty set.
5039
6289
  this.lastRenderedScene = this._scene;
5040
6290
  this.lastRenderedEnteredGroup = this._enteredGroup;
6291
+ this.lastRenderedHoveredSticky = this._hoveredStickyId;
5041
6292
  this.lastRenderedEraseActive = snapshot.eraseActive;
5042
6293
  // The forced erase repaint (if any) has now happened — later idle frames
5043
6294
  // (cursor moving / trail fading) skip the full main pass until the next cut.