@voithos-labs/aragonite 0.10.2 → 0.10.4

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 (300) hide show
  1. package/README.md +6 -21
  2. package/THIRD-PARTY-NOTICES.md +25 -0
  3. package/dist/a11y-strings.d.ts +19 -0
  4. package/dist/a11y-strings.js +19 -0
  5. package/dist/action-contracts.d.ts +7 -1
  6. package/dist/ambient/ambient-dom.js +5 -1
  7. package/dist/block-component.d.ts +14 -0
  8. package/dist/components/BlockDragHandle.svelte +37 -24
  9. package/dist/components/BlockHost.svelte +18 -11
  10. package/dist/components/Editor.svelte +434 -198
  11. package/dist/components/Editor.svelte.d.ts +1 -1
  12. package/dist/components/SelectionOverlay.svelte +25 -17
  13. package/dist/components/SelectionOverlay.svelte.d.ts +1 -1
  14. package/dist/components/TailInsert.svelte +107 -0
  15. package/dist/components/TailInsert.svelte.d.ts +17 -0
  16. package/dist/components/block-content-selector.d.ts +6 -2
  17. package/dist/components/block-content-selector.js +6 -2
  18. package/dist/components/blocks/ThematicBreakBlock.svelte +19 -6
  19. package/dist/components/blocks/ThematicBreakBlock.svelte.d.ts +1 -0
  20. package/dist/components/blocks/code/CodeBlock.svelte +211 -30
  21. package/dist/components/blocks/code/CodeBlockRail.svelte +704 -0
  22. package/dist/components/blocks/code/CodeBlockRail.svelte.d.ts +26 -0
  23. package/dist/components/blocks/code/code-bootstrap.js +16 -0
  24. package/dist/components/blocks/code/code-context-actions.d.ts +1 -0
  25. package/dist/components/blocks/code/code-context-actions.js +24 -0
  26. package/dist/components/blocks/code/code-fence-exit.d.ts +15 -0
  27. package/dist/components/blocks/code/code-fence-exit.js +26 -0
  28. package/dist/components/blocks/code/code-languages.d.ts +6 -0
  29. package/dist/components/blocks/code/code-languages.js +22 -3
  30. package/dist/components/blocks/code/code-renderer.js +11 -0
  31. package/dist/components/blocks/directive/DirectiveContainerBlock.svelte +1 -1
  32. package/dist/components/blocks/editable-leaf.d.ts +37 -6
  33. package/dist/components/blocks/editable-leaf.js +248 -30
  34. package/dist/components/blocks/editable-surface.d.ts +9 -0
  35. package/dist/components/blocks/editable-surface.js +22 -3
  36. package/dist/components/blocks/list/ListBlock.svelte +1 -0
  37. package/dist/components/blocks/list/ListItemBlock.svelte +14 -5
  38. package/dist/components/blocks/list/ListItemBlock.svelte.d.ts +2 -0
  39. package/dist/components/blocks/list/task-checkbox.d.ts +2 -0
  40. package/dist/components/blocks/list/task-checkbox.js +11 -2
  41. package/dist/components/blocks/surface-wiring.svelte.d.ts +4 -0
  42. package/dist/components/blocks/surface-wiring.svelte.js +8 -1
  43. package/dist/components/blocks/table/TableActionMenu.svelte +210 -86
  44. package/dist/components/blocks/table/TableActionMenu.svelte.d.ts +5 -0
  45. package/dist/components/blocks/table/TableBlock.svelte +205 -186
  46. package/dist/components/blocks/table/TableBlock.svelte.d.ts +1 -0
  47. package/dist/components/blocks/table/TableCellBlock.svelte +87 -22
  48. package/dist/components/blocks/table/TableRowBlock.svelte +4 -18
  49. package/dist/components/blocks/table/TableRowBlock.svelte.d.ts +0 -5
  50. package/dist/components/blocks/table/cell-clipboard.d.ts +10 -0
  51. package/dist/components/blocks/table/cell-clipboard.js +34 -1
  52. package/dist/components/blocks/table/cell-keydown-plan.d.ts +1 -1
  53. package/dist/components/blocks/table/cell-keydown-plan.js +3 -1
  54. package/dist/components/blocks/table/table-cell-paste.js +2 -1
  55. package/dist/components/blocks/table/table-menu-model.d.ts +35 -8
  56. package/dist/components/blocks/table/table-menu-model.js +36 -17
  57. package/dist/components/blocks/text/TextEditableBlock.svelte +58 -11
  58. package/dist/components/blocks/text/click-snap-guard.d.ts +3 -0
  59. package/dist/components/blocks/text/click-snap-guard.js +8 -0
  60. package/dist/components/blocks/text/delimiter-autopair.d.ts +71 -0
  61. package/dist/components/blocks/text/delimiter-autopair.js +216 -0
  62. package/dist/components/blocks/text/edge-policy-dispatch.d.ts +4 -0
  63. package/dist/components/blocks/text/edge-policy-dispatch.js +89 -20
  64. package/dist/components/blocks/text/live-selection-edit.d.ts +5 -5
  65. package/dist/components/blocks/text/live-selection-edit.js +37 -7
  66. package/dist/components/blocks/text/text-clipboard.js +2 -2
  67. package/dist/components/blocks/text/text-keydown.d.ts +7 -1
  68. package/dist/components/blocks/text/text-keydown.js +11 -1
  69. package/dist/components/blocks/text/text-render.d.ts +1 -1
  70. package/dist/components/blocks/text/text-render.js +3 -1
  71. package/dist/components/blocks/text/widget-interaction.d.ts +3 -0
  72. package/dist/components/blocks/text/widget-interaction.js +181 -37
  73. package/dist/components/drag-handle.d.ts +41 -0
  74. package/dist/components/drag-handle.js +134 -0
  75. package/dist/components/editor-root-focus.d.ts +19 -0
  76. package/dist/components/editor-root-focus.js +67 -0
  77. package/dist/components/editor-root-geometry.d.ts +39 -0
  78. package/dist/components/editor-root-geometry.js +91 -0
  79. package/dist/components/editor-root-keydown.d.ts +1 -1
  80. package/dist/components/editor-root-keydown.js +12 -3
  81. package/dist/components/editor-root-listeners.d.ts +3 -6
  82. package/dist/components/editor-root-listeners.js +3 -24
  83. package/dist/components/editor-root-mode-flip.d.ts +36 -0
  84. package/dist/components/editor-root-mode-flip.js +92 -0
  85. package/dist/components/image/ImageOverlayHost.svelte +14 -6
  86. package/dist/components/image/ImageProperties.svelte +512 -70
  87. package/dist/components/image/ImageProperties.svelte.d.ts +6 -1
  88. package/dist/components/image/ImageResizeHandles.svelte +67 -34
  89. package/dist/components/image/image-crop.d.ts +39 -0
  90. package/dist/components/image/image-crop.js +74 -0
  91. package/dist/components/image/image-edit-commit.d.ts +1 -0
  92. package/dist/components/image/image-edit-commit.js +21 -5
  93. package/dist/components/image/image-source-bytes.js +10 -3
  94. package/dist/components/image/image-widget-editing.js +1 -0
  95. package/dist/components/image/widget-dom.js +5 -1
  96. package/dist/components/link-card/link-card-commit.js +1 -1
  97. package/dist/components/lrd-map-gate.js +1 -1
  98. package/dist/components/menu/BlockMenu.svelte +315 -0
  99. package/dist/components/menu/BlockMenu.svelte.d.ts +34 -0
  100. package/dist/components/menu/MenuIcon.svelte +153 -0
  101. package/dist/components/menu/MenuIcon.svelte.d.ts +51 -0
  102. package/dist/components/menu/SelectionToolbar.svelte +385 -0
  103. package/dist/components/menu/SelectionToolbar.svelte.d.ts +16 -0
  104. package/dist/components/menu/clipboard-actions.d.ts +12 -0
  105. package/dist/components/menu/clipboard-actions.js +42 -0
  106. package/dist/components/menu/default-context-actions.d.ts +15 -0
  107. package/dist/components/menu/default-context-actions.js +76 -0
  108. package/dist/components/menu/flyout-placement.d.ts +6 -0
  109. package/dist/components/menu/flyout-placement.js +25 -0
  110. package/dist/core/inline/format-toggle.d.ts +12 -4
  111. package/dist/core/inline/format-toggle.js +94 -40
  112. package/dist/core/inline/image-dimensions.d.ts +3 -0
  113. package/dist/core/inline/image-dimensions.js +46 -10
  114. package/dist/core/inline/inline-widgets.d.ts +19 -0
  115. package/dist/core/inline/inline-widgets.js +5 -0
  116. package/dist/core/inline/scan/brackets.js +1 -0
  117. package/dist/core/inline/scan/plugin-syntax.d.ts +8 -0
  118. package/dist/core/inline/scan/plugin-syntax.js +15 -1
  119. package/dist/core/inline/transparency.js +3 -3
  120. package/dist/core/inline-render.d.ts +6 -0
  121. package/dist/core/inline-render.js +32 -0
  122. package/dist/core/nodes.d.ts +14 -0
  123. package/dist/cursor/edge-affinity.js +2 -1
  124. package/dist/cursor/height-oracle.d.ts +2 -3
  125. package/dist/cursor/height-oracle.js +0 -1
  126. package/dist/cursor/overlay-remeasure.js +8 -0
  127. package/dist/cursor/reveal-source.js +7 -2
  128. package/dist/cursor/scroll-hold.d.ts +10 -0
  129. package/dist/cursor/scroll-hold.js +22 -0
  130. package/dist/cursor/scrollport.d.ts +9 -0
  131. package/dist/cursor/scrollport.js +30 -1
  132. package/dist/cursor/visual-lines.d.ts +5 -4
  133. package/dist/cursor/visual-lines.js +56 -11
  134. package/dist/cursor/widget-edge-snap.d.ts +28 -0
  135. package/dist/cursor/widget-edge-snap.js +38 -0
  136. package/dist/cursor/widget-offset.d.ts +9 -0
  137. package/dist/cursor/widget-offset.js +71 -4
  138. package/dist/debug/interaction-trace.d.ts +4 -0
  139. package/dist/debug/interaction-trace.js +15 -0
  140. package/dist/decorations/decoration-state.svelte.js +1 -1
  141. package/dist/decorations/reserved-attrs.js +1 -0
  142. package/dist/editor-actions/ancestry-folds.d.ts +2 -2
  143. package/dist/editor-actions/ancestry-folds.js +1 -1
  144. package/dist/editor-actions/block-edit-scope.js +1 -1
  145. package/dist/editor-actions/commit/text-batch.d.ts +3 -2
  146. package/dist/editor-actions/commit/text-batch.js +1 -1
  147. package/dist/editor-actions/commit/undo-controller.js +4 -2
  148. package/dist/editor-actions/container-edit.js +2 -1
  149. package/dist/editor-actions/enter-completion.d.ts +2 -0
  150. package/dist/editor-actions/enter-completion.js +23 -2
  151. package/dist/editor-actions/focus/focus-dispatch.js +7 -4
  152. package/dist/editor-actions/focus/focus-landing.d.ts +10 -1
  153. package/dist/editor-actions/focus/focus-landing.js +20 -6
  154. package/dist/editor-actions/inline-range-commit.js +1 -1
  155. package/dist/editor-actions/plugin/container.d.ts +7 -0
  156. package/dist/editor-actions/plugin/container.js +3 -1
  157. package/dist/editor-actions/reorder-action.js +19 -10
  158. package/dist/editor-actions/reorder-drag.js +29 -1
  159. package/dist/editor-actions/replacement-focus.d.ts +1 -1
  160. package/dist/editor-actions/replacement-focus.js +1 -1
  161. package/dist/editor-actions/search-replace.js +1 -1
  162. package/dist/editor-actions/table-context.d.ts +4 -1
  163. package/dist/editor-actions/table-context.js +57 -1
  164. package/dist/editor-events.d.ts +3 -0
  165. package/dist/editor-keys.d.ts +33 -0
  166. package/dist/editor-props.d.ts +24 -12
  167. package/dist/index.d.ts +1 -1
  168. package/dist/plugin.d.ts +7 -0
  169. package/dist/plugin.js +16 -0
  170. package/dist/plugins/latex/BlockMath.svelte +264 -29
  171. package/dist/plugins/latex/BlockMath.svelte.d.ts +2 -0
  172. package/dist/plugins/latex/index.d.ts +2 -1
  173. package/dist/plugins/latex/latex-kind.js +60 -10
  174. package/dist/plugins/latex/math-completion.js +4 -1
  175. package/dist/plugins/latex/math-layout.d.ts +15 -0
  176. package/dist/plugins/latex/math-layout.js +13 -0
  177. package/dist/plugins/latex/math-source.d.ts +19 -0
  178. package/dist/plugins/latex/math-source.js +103 -0
  179. package/dist/plugins/latex/register.d.ts +11 -2
  180. package/dist/plugins/latex/register.js +3 -1
  181. package/dist/plugins/latex/renderer.d.ts +3 -3
  182. package/dist/plugins/latex/renderer.js +13 -6
  183. package/dist/plugins/mermaid/MermaidBlock.svelte +19 -9
  184. package/dist/plugins/parrot/ParrotBlock.svelte +3 -1
  185. package/dist/reactivity/list-windowing.svelte.d.ts +8 -8
  186. package/dist/reactivity/list-windowing.svelte.js +75 -30
  187. package/dist/schema/block-completions.d.ts +8 -0
  188. package/dist/schema/block-completions.js +11 -0
  189. package/dist/schema/commands.d.ts +9 -5
  190. package/dist/schema/commands.js +12 -7
  191. package/dist/schema/context-actions.d.ts +31 -0
  192. package/dist/schema/context-actions.js +23 -0
  193. package/dist/schema/fenced-code-raw.js +31 -1
  194. package/dist/schema/operations.d.ts +8 -1
  195. package/dist/schema/reserved-chords.js +37 -4
  196. package/dist/schema/table-cell-raw.d.ts +1 -1
  197. package/dist/schema/table-cell-raw.js +1 -1
  198. package/dist/selection/block-hit-test.js +3 -2
  199. package/dist/selection/char-endpoint-snap.js +1 -1
  200. package/dist/selection/clipboard-text.js +6 -1
  201. package/dist/selection/covered-block.d.ts +10 -0
  202. package/dist/selection/covered-block.js +24 -0
  203. package/dist/selection/cross-block/dispatch.d.ts +3 -0
  204. package/dist/selection/cross-block/dispatch.js +13 -1
  205. package/dist/selection/cross-block/format-range.d.ts +1 -1
  206. package/dist/selection/cross-block/format-range.js +4 -15
  207. package/dist/selection/cross-block/format-toggle.js +1 -1
  208. package/dist/selection/cross-block/keydown.js +3 -29
  209. package/dist/selection/cross-block/ops.js +1 -1
  210. package/dist/selection/cross-block/paste.js +31 -35
  211. package/dist/selection/cross-block/type-replace.d.ts +3 -2
  212. package/dist/selection/cross-block/type-replace.js +52 -13
  213. package/dist/selection/dead-space-caret.d.ts +13 -0
  214. package/dist/selection/dead-space-caret.js +57 -1
  215. package/dist/selection/drag-pointer.d.ts +26 -2
  216. package/dist/selection/drag-pointer.js +103 -2
  217. package/dist/selection/gap-caret.js +1 -1
  218. package/dist/selection/keyboard-extend.d.ts +3 -2
  219. package/dist/selection/keyboard-extend.js +4 -3
  220. package/dist/selection/multi-click.d.ts +42 -0
  221. package/dist/selection/multi-click.js +203 -0
  222. package/dist/selection/native-bridge.d.ts +3 -0
  223. package/dist/selection/native-bridge.js +31 -2
  224. package/dist/selection/path-lookup.d.ts +10 -2
  225. package/dist/selection/path-lookup.js +11 -4
  226. package/dist/selection/pointer-gesture.d.ts +9 -0
  227. package/dist/selection/pointer-gesture.js +11 -0
  228. package/dist/selection/primitives.d.ts +7 -0
  229. package/dist/selection/primitives.js +19 -1
  230. package/dist/selection/range-delete-ceremony.js +4 -2
  231. package/dist/selection/range-delete-chrome.js +2 -1
  232. package/dist/selection/range-delete-table-coverage.js +2 -1
  233. package/dist/selection/range-delete-table.js +3 -2
  234. package/dist/selection/range-delete.d.ts +4 -0
  235. package/dist/selection/range-delete.js +31 -3
  236. package/dist/selection/selection-drop.d.ts +34 -0
  237. package/dist/selection/selection-drop.js +253 -0
  238. package/dist/selection/selection-restore.js +1 -1
  239. package/dist/selection/selection-state.svelte.d.ts +6 -0
  240. package/dist/selection/selection-state.svelte.js +36 -1
  241. package/dist/selection/table-endpoint-snap.js +1 -1
  242. package/dist/selection/table-rect-extend.js +1 -1
  243. package/dist/styles/editor-theme.css +75 -31
  244. package/dist/styles/editor.css +268 -18
  245. package/dist/testing/container-conformance.js +2 -2
  246. package/dist/testing/inline-conformance.js +2 -1
  247. package/dist/tree-operations/blockquote.js +1 -1
  248. package/dist/tree-operations/chain-rebuild.d.ts +63 -0
  249. package/dist/tree-operations/chain-rebuild.js +142 -0
  250. package/dist/tree-operations/children.d.ts +1 -1
  251. package/dist/tree-operations/children.js +1 -1
  252. package/dist/tree-operations/cleanup.js +1 -1
  253. package/dist/tree-operations/content-write.d.ts +50 -0
  254. package/dist/tree-operations/content-write.js +263 -0
  255. package/dist/tree-operations/index.d.ts +8 -3
  256. package/dist/tree-operations/index.js +6 -2
  257. package/dist/tree-operations/list/exit-replacement.js +1 -1
  258. package/dist/tree-operations/list/unwrap-merge.js +3 -3
  259. package/dist/tree-operations/node-ops.d.ts +17 -234
  260. package/dist/tree-operations/node-ops.js +47 -1113
  261. package/dist/tree-operations/node-primitives.d.ts +75 -0
  262. package/dist/tree-operations/node-primitives.js +117 -0
  263. package/dist/tree-operations/paste/apply.js +1 -1
  264. package/dist/tree-operations/paste/body-write.d.ts +1 -1
  265. package/dist/tree-operations/paste/body-write.js +2 -2
  266. package/dist/tree-operations/paste/container-match.js +4 -2
  267. package/dist/tree-operations/paste/dispatch.js +2 -1
  268. package/dist/tree-operations/paste/find-enclosing-list.js +1 -1
  269. package/dist/tree-operations/paste/focus-target.d.ts +1 -1
  270. package/dist/tree-operations/paste/list-absorb.js +1 -1
  271. package/dist/tree-operations/paste/list-break-out.js +1 -1
  272. package/dist/tree-operations/paste/parent-scope.js +1 -1
  273. package/dist/tree-operations/paste/paste-replacement.js +1 -1
  274. package/dist/tree-operations/paste/replace-block-at-parent.d.ts +3 -1
  275. package/dist/tree-operations/paste/replace-block-at-parent.js +5 -2
  276. package/dist/tree-operations/paste/replacement-parse.d.ts +17 -0
  277. package/dist/tree-operations/paste/replacement-parse.js +22 -0
  278. package/dist/tree-operations/path-mutate.d.ts +1 -1
  279. package/dist/tree-operations/path-mutate.js +2 -1
  280. package/dist/tree-operations/reorder-unit.js +1 -1
  281. package/dist/tree-operations/reorder.d.ts +5 -2
  282. package/dist/tree-operations/reorder.js +55 -2
  283. package/dist/tree-operations/settle.d.ts +105 -0
  284. package/dist/tree-operations/settle.js +660 -0
  285. package/dist/tree-operations/table-grid-clipboard.d.ts +21 -0
  286. package/dist/tree-operations/table-grid-clipboard.js +90 -0
  287. package/dist/tree-operations/unshare.d.ts +15 -77
  288. package/dist/tree-operations/unshare.js +15 -166
  289. package/docs/guide/consumer-guide.md +136 -104
  290. package/docs/guide/plugin-api.md +73 -29
  291. package/docs/guide/plugin-guide.md +55 -23
  292. package/package.json +9 -7
  293. package/dist/components/blocks/code/CodeLanguageChip.svelte +0 -127
  294. package/dist/components/blocks/code/CodeLanguageChip.svelte.d.ts +0 -14
  295. package/dist/components/blocks/table/TableGrip.svelte +0 -91
  296. package/dist/components/blocks/table/TableGrip.svelte.d.ts +0 -8
  297. package/dist/components/blocks/table/table-drop-target.d.ts +0 -1
  298. package/dist/components/blocks/table/table-drop-target.js +0 -16
  299. package/dist/components/blocks/table/table-reorder-drag.d.ts +0 -78
  300. package/dist/components/blocks/table/table-reorder-drag.js +0 -97
@@ -28,7 +28,7 @@
28
28
  import { createPendingMarksState } from '../cursor/pending-marks';
29
29
  import { createRevealAnchorState } from '../cursor/reveal-anchor';
30
30
  import { createHeightOracle } from '../cursor/height-oracle';
31
- import { ESTIMATE_BASE_FONT_SIZE, HEIGHT_ESTIMATES } from '../cursor/typography-estimates';
31
+ import { HEIGHT_ESTIMATES } from '../cursor/typography-estimates';
32
32
  import {
33
33
  clippingAncestors,
34
34
  userScrollportFor,
@@ -37,18 +37,37 @@
37
37
  import { createScrollport, type Scrollport } from '../cursor/scrollport';
38
38
  import { createDeadSpaceCaret } from '../selection/dead-space-caret';
39
39
  import { resetForPointerDown } from '../selection/cross-block/pointer';
40
+ import { installDragListener } from '../selection/drag-pointer';
41
+ import { installMultiClickSelect } from '../selection/multi-click';
42
+ import { claimsPointerGesture } from '../selection/pointer-gesture';
43
+ import { installSelectionDrop } from '../selection/selection-drop';
40
44
  import { createContentVersion } from '../reactivity/content-version.svelte';
41
45
  import { useContainerWindowing } from '../reactivity/use-container-windowing.svelte';
42
46
  import { refSlotsOver, replaceRefs, revealChildOrWait } from '../reactivity/publish-ref.svelte';
43
47
  import { createSelectionState } from '../selection/selection-state.svelte';
44
48
  import { createSelectionDescription } from '../selection/selection-description';
45
- import { EDITOR_LABEL, movedBlockToPosition } from '../a11y-strings';
49
+ import {
50
+ BLOCK_ACTIONS_LABEL,
51
+ BLOCK_MENU_LABEL,
52
+ EDITOR_LABEL,
53
+ movedBlockToPosition
54
+ } from '../a11y-strings';
55
+ import TailInsert from './TailInsert.svelte';
56
+ import BlockMenu, {
57
+ insertFlyoutEntries,
58
+ insertMenuEntries,
59
+ insertSnippets,
60
+ type MenuEntry
61
+ } from './menu/BlockMenu.svelte';
62
+ import { runClipboardAction, type ClipboardAction } from './menu/clipboard-actions';
63
+ import { blockContextActionsFor, type BlockContextAction } from '../schema/context-actions';
64
+ import { isProseBackground, registerDefaultContextActions } from './menu/default-context-actions';
46
65
  import type { EditorSelection } from '../selection/primitives';
47
66
  import { createWidgetSelectionState } from './image/widget-selection-state.svelte';
48
67
  import { bootstrapCodeLanguages } from './blocks/code/code-bootstrap';
49
68
  import { assignIds } from '../block-id';
50
69
  import { ensureEditableContainers, emptyParagraph } from '../tree-operations';
51
- import { blockNodeAt, isBlockNode, nodeAt } from '../tree-operations/node-ops';
70
+ import { blockNodeAt, isBlockNode, nodeAt } from '../tree-operations/node-primitives';
52
71
  import { serialize } from '../core/serializer';
53
72
  import { parse } from '../core/parser';
54
73
  import { defaultLinkActivation } from '../core/url-policy';
@@ -80,19 +99,25 @@
80
99
  } from '../debug/interaction-trace';
81
100
  import { readCurrentSelection } from '../selection/native-bridge';
82
101
  import { restoreSelection, type SelectionRestoreOutcome } from '../selection/selection-restore';
83
- import { findSurfacePathForElement, readBlockPath } from '../selection/path-lookup';
102
+ import { findSurfacePathForElement } from '../selection/path-lookup';
84
103
  import { createCaretRestore } from '../selection/caret-restore';
85
104
  import { createCrossBlockHandlers } from '../selection/cross-block/dispatch';
86
105
  import { createCrossBlockCommands } from '../selection/cross-block/format-toggle';
87
- import { isPreviewMode } from '../presentation-mode';
88
106
  import { normalizeKeybindingOverrides } from '../schema/keybinding-overrides';
89
107
  import { createEditorRootKeydown } from './editor-root-keydown';
90
108
  import { createEditorRootClipboard } from './editor-root-clipboard';
109
+ import { createModeFlip } from './editor-root-mode-flip';
110
+ import { createFocusAttribution } from './editor-root-focus';
111
+ import {
112
+ installHeaderSlotCompensation,
113
+ installTypeScaleProbe,
114
+ installViewportHeightWatcher,
115
+ installWidthWatcher
116
+ } from './editor-root-geometry';
91
117
  import {
92
118
  installEditorBlurAnnouncer,
93
119
  installModActiveTracker,
94
120
  installSelectionChangeBridge,
95
- installViewportHeightWatcher,
96
121
  onRoot,
97
122
  removeAll
98
123
  } from './editor-root-listeners';
@@ -102,12 +127,8 @@
102
127
  registerEditor,
103
128
  unregisterEditor,
104
129
  markEditorInteracted,
105
- releaseInteractedEditor,
106
- isTextEntrySurface
130
+ releaseInteractedEditor
107
131
  } from '../active-editor';
108
- import { ambientLengthOf } from '../ambient/ambient-dom';
109
- import { toClampedRawOffset } from '../cursor/coordinate-spaces';
110
- import { domTextOffsetAtNode } from '../cursor/widget-offset';
111
132
  import {
112
133
  canRunCommandById,
113
134
  isCommandActiveById,
@@ -127,6 +148,7 @@
127
148
  import { bothEnable, createRegistryView, type KindEnablement } from '../schema/registry-view';
128
149
  import BlockList from './BlockList.svelte';
129
150
  import SearchBar from './SearchBar.svelte';
151
+ import SelectionToolbar from './menu/SelectionToolbar.svelte';
130
152
  import ImageOverlayHost from './image/ImageOverlayHost.svelte';
131
153
  import LinkCardHost from './link-card/LinkCardHost.svelte';
132
154
  import { createLinkCardState } from './link-card/link-card-state.svelte';
@@ -134,12 +156,12 @@
134
156
  import { runStartupInvariantChecks } from '../invariants/install';
135
157
  import { assertInvariant } from '../assert';
136
158
  import { checkMarkerCssParity } from '../invariants/marker-css-parity';
137
- import { checkLandableCaret } from '../invariants/landable-caret';
138
159
  import { registerBuiltInBlocks } from './built-in-blocks';
139
160
  import { BLOCK_CONTENT_SELECTOR } from './block-content-selector';
140
161
 
141
162
  registerBuiltInBlocks();
142
163
  bootstrapCodeLanguages();
164
+ registerDefaultContextActions();
143
165
  runStartupInvariantChecks();
144
166
 
145
167
  // `__registryEnablement` is a harness-only door; the intersection keeps it off the
@@ -151,10 +173,13 @@
151
173
  imageLoadPolicy = 'auto',
152
174
  onLinkActivate,
153
175
  onPasteImage,
176
+ onRunCode,
177
+ codeMenuItems,
154
178
  header,
155
- blockDragHandles = false,
179
+ blockDragHandles = true,
156
180
  searchBar = true,
157
181
  searchBarAnchor,
182
+ selectionToolbar = true,
158
183
  keybindings,
159
184
  theme = 'dark',
160
185
  presentationMode = 'source',
@@ -447,6 +472,189 @@
447
472
  revealBlock: (index) => revealPath([index])
448
473
  });
449
474
 
475
+ // ── Block menu ──────────────────────────────────────────────────────
476
+
477
+ // Opened by the bottom `+` and by a right-click on prose with nothing selected; a right-click
478
+ // over a selection leaves the formatting popover (the host's) in charge and only suppresses
479
+ // the native menu. Tables run their own cell menu and have prevented the default first.
480
+ let blockMenu = $state<{
481
+ x: number;
482
+ y: number;
483
+ anchor: () => { x: number; y: number };
484
+ items: MenuEntry[];
485
+ label: string;
486
+ pick: (id: string) => void;
487
+ } | null>(null);
488
+
489
+ // Open/close transitions only, never the mount, so a subscriber's first news is a real menu.
490
+ let menuWasOpen = false;
491
+ $effect(() => {
492
+ const open = blockMenu !== null;
493
+ if (open === menuWasOpen) return;
494
+ menuWasOpen = open;
495
+ events.emit('menuChange', open);
496
+ });
497
+
498
+ function anchorOn(el: Element, point: { x: number; y: number }): () => { x: number; y: number } {
499
+ const rect = el.getBoundingClientRect();
500
+ const dx = point.x - rect.left;
501
+ const dy = point.y - rect.top;
502
+ return () => {
503
+ const now = el.getBoundingClientRect();
504
+ return { x: now.left + dx, y: now.top + dy };
505
+ };
506
+ }
507
+
508
+ // A right-click on a BLOCK — a fence, an equation, an image — is that block's context menu:
509
+ // its kind's registered actions, then the defaults. Prose is the page's background and keeps
510
+ // the browser's own menu (spelling, the platform's clipboard), as does a selection, whose
511
+ // affordance is the host's formatting popover. The margin shows nothing at all.
512
+ function onRootContextMenu(e: MouseEvent): void {
513
+ if (e.defaultPrevented || effectiveMode === 'reading' || !editorEl) return;
514
+ const target = e.target instanceof Element ? e.target : null;
515
+ if (!target || isHostChrome(target)) return;
516
+ e.preventDefault();
517
+ // The keyboard's contextmenu event (Windows fires it on the ContextMenu key's release)
518
+ // lands on the item the keydown's own menu already focused: that menu is the answer.
519
+ if (target.closest('.md-menu')) return;
520
+ const host = target.closest<HTMLElement>('.block-host[data-block-path]');
521
+ const path = pathOf(host);
522
+ if (!host || !path) return;
523
+ const point = { x: e.clientX, y: e.clientY };
524
+ const native = window.getSelection();
525
+ const selected = !!native && !native.isCollapsed && editorEl.contains(native.anchorNode);
526
+ // A selection's menu is the clipboard's, over the selection as it stands. So is prose's
527
+ // (the page's background), at a caret placed where the press landed; a nested block (a
528
+ // fence inside a list item) takes the same for now.
529
+ const node = path.length === 1 ? doc.children[path[0]] : undefined;
530
+ if (selected || !node || isProseBackground(node)) {
531
+ if (!selected) placeCaretAtPoint(e.clientX, e.clientY);
532
+ // Top-level prose is where a sibling block makes sense; a nested block or a selection
533
+ // gets the clipboard alone.
534
+ const insertAfter = !selected && node && isProseBackground(node) ? path[0] : null;
535
+ openClipboardMenu(point, host, insertAfter);
536
+ return;
537
+ }
538
+ const index = path[0];
539
+ const actions = blockContextActionsFor(node, path);
540
+ if (actions.length === 0) return;
541
+ const ctx = {
542
+ node,
543
+ path,
544
+ deleteBlock: async () => {
545
+ await blockEdit.deleteBlock(index);
546
+ },
547
+ replaceRaw: async (raw: string) => {
548
+ await blockEdit.updateBlockContent(index, raw);
549
+ }
550
+ };
551
+ blockMenu = {
552
+ ...point,
553
+ anchor: anchorOn(host, point),
554
+ items: actions.map(({ id, label, icon, danger }) => ({
555
+ id,
556
+ label,
557
+ icon: icon as MenuEntry['icon'],
558
+ danger
559
+ })),
560
+ label: BLOCK_ACTIONS_LABEL,
561
+ pick: (id) => {
562
+ blockMenu = null;
563
+ const action = actions.find((a: BlockContextAction) => a.id === id);
564
+ if (action) void action.run(ctx);
565
+ }
566
+ };
567
+ }
568
+
569
+ // The editing surface a paste goes to: whatever editable holds focus inside the root.
570
+ function focusedEditable(): HTMLElement | null {
571
+ const active = document.activeElement;
572
+ return active instanceof HTMLElement && editorEl?.contains(active) && active.isContentEditable
573
+ ? active
574
+ : null;
575
+ }
576
+
577
+ function clipboardRows(): MenuEntry[] {
578
+ const selected = !!focusedEditable() && !(window.getSelection()?.isCollapsed ?? true);
579
+ return [
580
+ { id: 'clip.cut', label: 'Cut', icon: 'scissors', disabled: !selected },
581
+ { id: 'clip.copy', label: 'Copy', icon: 'copy', disabled: !selected },
582
+ { id: 'clip.paste', label: 'Paste', icon: 'clipboard' },
583
+ { id: 'clip.paste-plain', label: 'Paste as plain text', icon: 'type' }
584
+ ];
585
+ }
586
+
587
+ function runClipboardRow(id: string): boolean {
588
+ if (!id.startsWith('clip.')) return false;
589
+ void runClipboardAction(id.slice('clip.'.length) as ClipboardAction, focusedEditable());
590
+ return true;
591
+ }
592
+
593
+ /** `insertAfter` names the top-level block an "Insert block" flyout mints an empty sibling
594
+ * after; null leaves the menu to the clipboard rows alone. */
595
+ function openClipboardMenu(
596
+ point: { x: number; y: number },
597
+ anchorEl: Element,
598
+ insertAfter: number | null = null
599
+ ): void {
600
+ const insert: MenuEntry[] =
601
+ insertAfter === null
602
+ ? []
603
+ : [
604
+ { id: 'sep', label: '', divider: true },
605
+ { id: 'insert', label: 'Insert block', icon: 'plus', children: insertFlyoutEntries() }
606
+ ];
607
+ blockMenu = {
608
+ ...point,
609
+ anchor: anchorOn(anchorEl, point),
610
+ items: [...clipboardRows(), ...insert],
611
+ label: BLOCK_ACTIONS_LABEL,
612
+ pick: (id) => {
613
+ blockMenu = null;
614
+ if (runClipboardRow(id)) return;
615
+ const md = insertSnippets().get(id);
616
+ if (md && insertAfter !== null) void insertBlockAfter(insertAfter, md);
617
+ }
618
+ };
619
+ }
620
+
621
+ // The same two steps the tail's `+` takes: mint the empty paragraph, which lands the caret in
622
+ // it, then hand the snippet to the surface that now holds focus.
623
+ async function insertBlockAfter(index: number, md: string): Promise<void> {
624
+ await blockEdit.insertParagraph(index + 1, '');
625
+ insertMarkdown(md);
626
+ }
627
+
628
+ function pathOf(host: HTMLElement | null): number[] | null {
629
+ const raw = host?.dataset.blockPath;
630
+ if (!raw) return null;
631
+ try {
632
+ const parsed: unknown = JSON.parse(raw);
633
+ return Array.isArray(parsed) && parsed.every((n) => typeof n === 'number') ? parsed : null;
634
+ } catch {
635
+ return null;
636
+ }
637
+ }
638
+
639
+ async function onTailPlus(button: HTMLElement): Promise<void> {
640
+ await blockEdit.insertParagraph(doc.children.length, '');
641
+ const rect = button.getBoundingClientRect();
642
+ const point = { x: rect.left, y: rect.bottom + 4 };
643
+ const snippets = insertSnippets();
644
+ blockMenu = {
645
+ ...point,
646
+ anchor: anchorOn(button, point),
647
+ items: [...insertMenuEntries(), { id: 'sep', label: '', divider: true }, ...clipboardRows()],
648
+ label: BLOCK_MENU_LABEL,
649
+ pick: (id) => {
650
+ blockMenu = null;
651
+ if (runClipboardRow(id)) return;
652
+ const md = snippets.get(id);
653
+ if (md) insertMarkdown(md);
654
+ }
655
+ };
656
+ }
657
+
450
658
  // ── Link card door ──────────────────────────────────────────────────
451
659
 
452
660
  // The caret snapshot and the entry guards ride the STATE, not the callers, so entry path N+1
@@ -509,9 +717,32 @@
509
717
  }
510
718
  const anchor = target?.closest('a[href]') as HTMLAnchorElement | null;
511
719
  // The helper claims only clicks whose target IS the root, so nothing the
512
- // editor renders is touched.
720
+ // editor renders is touched. A margin drag's release arrives as a root click too (the
721
+ // press and release targets meet at the root); with a range just painted, it is no
722
+ // click to answer.
513
723
  if (!anchor) {
514
- deadSpaceCaret.handleClick(root, e);
724
+ // A multi-click places no caret: the ladder painted its range over this press.
725
+ if (e.detail >= 2) {
726
+ marginDrag = false;
727
+ return;
728
+ }
729
+ const pressed = marginDrag;
730
+ const dragged =
731
+ pressed &&
732
+ (Math.abs(e.clientX - marginDown.x) > 3 || Math.abs(e.clientY - marginDown.y) > 3);
733
+ marginDrag = false;
734
+ if (dragged) return;
735
+ if (deadSpaceCaret.handleClick(root, e)) return;
736
+ // A press the editor took on a block (a host's own padding beside a table, a rule, a
737
+ // folded equation's face) that did not move is a click on it: the click helper
738
+ // claims only dead space, so the same landing is resolved here.
739
+ if (pressed && !deadSpaceCaret.isDeadSpaceTarget(root, e.target)) {
740
+ if (placeCaretAtPoint(e.clientX, e.clientY)) return;
741
+ }
742
+ // Declined everywhere: a click on nothing still LEAVES what was being edited (a
743
+ // revealed equation folds on blur), since the margin press suppressed the blur the
744
+ // browser would have done.
745
+ if (pressed) blurEditingSurface(root);
515
746
  return;
516
747
  }
517
748
  // Host chrome follows the page's link behaviour, not plain-click-edits.
@@ -529,9 +760,92 @@
529
760
  e.preventDefault();
530
761
  }
531
762
  };
763
+ // Dead space, and the parts of a block that are neither editable nor controls: a rendered
764
+ // equation, a diagram, a card's rendered face. A press on any of them cannot grow a native
765
+ // selection, so the editor's drag runs from it.
766
+ // A whole-block input proxy is an editable in name only (it catches IME for a block with no
767
+ // text), so a press on it starts the editor's drag like a press on the block itself.
768
+ const NOT_A_DRAG_START =
769
+ '[contenteditable="true"]:not([data-whole-block-input]), button, input, textarea, select, ' +
770
+ 'a, summary, [role="checkbox"], ' +
771
+ '.code-rail, .table-add-zone, .editor-tail, .md-menu, .block-drag-handle';
772
+ const dragStartsHere = (rootEl: HTMLElement, target: EventTarget | null): boolean => {
773
+ if (claimsPointerGesture(target)) return false;
774
+ if (deadSpaceCaret.isDeadSpaceTarget(rootEl, target)) return true;
775
+ if (!(target instanceof Element) || !rootEl.contains(target)) return false;
776
+ return target.closest(NOT_A_DRAG_START) === null;
777
+ };
778
+ // A drag that STARTS in the margin: the browser cannot grow a selection from a
779
+ // non-editable press into an editable block, so the editor runs the drag itself, anchored
780
+ // where a click there would land. The mousedown's default is suppressed so the browser
781
+ // starts no selection of its own to fight it; `click` still fires for the caret placement.
782
+ let marginDrag = false;
783
+ let marginDown = { x: 0, y: 0 };
784
+ let marginSession: { dispose(): void } | null = null;
785
+ const startMarginDrag = (e: PointerEvent) => {
786
+ marginDrag = false;
787
+ marginDown = { x: e.clientX, y: e.clientY };
788
+ if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
789
+ if (effectiveMode === 'reading' || !dragStartsHere(root, e.target)) return;
790
+ const anchor = deadSpaceCaret.anchorAtPoint(root, e.clientX, e.clientY);
791
+ if (!anchor) return;
792
+ marginDrag = true;
793
+ resetForPointerDown(selectionState, stickyColumn, edgeAffinity, false);
794
+ // A block that runs its own drag from a nearby press (a table's cell rectangle) takes
795
+ // it; the generic drag is for blocks that have none.
796
+ if (!('offset' in anchor)) {
797
+ const component = getBlockComponent(anchor.path);
798
+ if (component?.startDragAtPoint?.(e.clientX, e.clientY, e)) return;
799
+ }
800
+ marginSession = installDragListener(
801
+ {
802
+ editorRoot: root,
803
+ scrollContainer: getScrollHost() ?? root,
804
+ selection: selectionState,
805
+ getBlockElByPath,
806
+ lifetimeSignal: lifetimeController.signal,
807
+ paintSameBlock: true
808
+ },
809
+ anchor,
810
+ e
811
+ );
812
+ };
532
813
  return removeAll(
533
814
  onRoot(root, 'click', handleClick),
534
- onRoot(root, 'mousedown', (e: MouseEvent) => deadSpaceCaret.notePress(root, e))
815
+ installMultiClickSelect({
816
+ editorRoot: root,
817
+ selection: selectionState,
818
+ getBlockElByPath,
819
+ getScrollContainer: () => getScrollHost() ?? root,
820
+ lifetimeSignal: lifetimeController.signal,
821
+ marginBlockAt: (target, x, y) =>
822
+ effectiveMode !== 'reading' && dragStartsHere(root, target)
823
+ ? deadSpaceCaret.blockPathNearPoint(root, x, y)
824
+ : null
825
+ }),
826
+ installSelectionDrop({
827
+ editorRoot: root,
828
+ getDoc: () => doc,
829
+ controller,
830
+ coordinator: pasteCoordinator,
831
+ getPresentationMode: () => effectiveMode,
832
+ linkRef: linkRefView,
833
+ grammar: registryView.grammar,
834
+ activePlugins,
835
+ events,
836
+ isReadOnly: () => effectiveMode === 'reading'
837
+ }),
838
+ onRoot(root, 'pointerdown', startMarginDrag),
839
+ onRoot(root, 'mousedown', (e: MouseEvent) => {
840
+ deadSpaceCaret.notePress(root, e);
841
+ // The second press of a click run is the ladder's, which runs its own drag.
842
+ if (marginDrag && e.detail >= 2) {
843
+ marginSession?.dispose();
844
+ marginSession = null;
845
+ marginDrag = false;
846
+ }
847
+ if (marginDrag) e.preventDefault();
848
+ })
535
849
  );
536
850
  });
537
851
 
@@ -862,8 +1176,7 @@
862
1176
  resolveImageUrl: resolveImageUrlImpl,
863
1177
  resolveLinkUrl: resolveLinkUrlImpl,
864
1178
  imageLoadPolicy: () => imageLoadPolicy,
865
- // Reading mode forces the affordances off through the prop's own funnel; the handle
866
- // and the table grips all read this one getter.
1179
+ // Reading mode forces the affordance off through the prop's own funnel.
867
1180
  blockDragHandles: () => blockDragHandles && effectiveMode !== 'reading',
868
1181
  presentationMode: () => effectiveMode,
869
1182
  theme: () => theme,
@@ -874,89 +1187,45 @@
874
1187
  get onPasteImage() {
875
1188
  return onPasteImage;
876
1189
  },
1190
+ // Accessors for the same reason as onPasteImage above.
1191
+ get onRunCode() {
1192
+ return onRunCode;
1193
+ },
1194
+ get codeMenuItems() {
1195
+ return codeMenuItems;
1196
+ },
877
1197
  brokenImageUrls
878
1198
  } satisfies EditorPolicies);
879
1199
 
880
1200
  // ── Root DOM effects ────────────────────────────────────────────────
881
1201
 
882
- /** The focused leaf's caret as (path, raw offset): the per-backend road while a leaf holds
883
- * focus, else the native range a toggle click leaves behind while chrome takes focus. */
884
- function captureFlipCaret(): { path: number[]; offset: number } | null {
885
- if (selectionState.isCrossBlock || selectionState.gapCaret) return null;
886
- const focused = readCurrentSelection(selectionState, blockRefs)?.focus;
887
- if (focused) return { path: focused.path, offset: focused.offset };
888
- const sel = window.getSelection();
889
- if (!sel?.focusNode || !editorEl?.contains(sel.focusNode) || isHostChrome(sel.focusNode))
890
- return null;
891
- const node = sel.focusNode;
892
- const el = node instanceof Element ? node : node.parentElement;
893
- const path = findSurfacePathForElement(el);
894
- if (!path) return null;
895
- const contentEl = getBlockElByPath(path);
896
- if (!contentEl?.contains(node)) return null;
897
- const offset = toClampedRawOffset(
898
- domTextOffsetAtNode(contentEl, node, sel.focusOffset),
899
- ambientLengthOf(contentEl)
900
- );
901
- return { path, offset };
902
- }
903
-
904
- // The flip's PRE phase, the last moment the outgoing mode owns the DOM: past it the mode's
905
- // render key has rebuilt every block from its own CST bytes, and the reveal's ephemeral edit
906
- // is gone. Reading keeps its entry snapshot — no caret of its own to recapture on the way out.
907
- let flipCaret: { path: number[]; offset: number } | null = null;
908
- // svelte-ignore state_referenced_locally
909
- let preFlipSeenMode = effectiveMode;
1202
+ // The pre half runs while the outgoing mode still owns the DOM, the post half after the
1203
+ // mode's render key has rebuilt every block; the factory carries the caret across the gap.
1204
+ const modeFlip = createModeFlip({
1205
+ get editorEl() {
1206
+ return editorEl;
1207
+ },
1208
+ get mode() {
1209
+ return effectiveMode;
1210
+ },
1211
+ selection: selectionState,
1212
+ getSelection,
1213
+ getBlockElByPath,
1214
+ isHostChrome,
1215
+ edgeAffinity,
1216
+ // Built below; a flip runs post-init, so the getter reads past the TDZ.
1217
+ get heightOracle() {
1218
+ return heightOracle;
1219
+ },
1220
+ events,
1221
+ restoreCaret: (path, offset) => restoreThroughRevealRoad(caretAt(path, offset), 'mount')
1222
+ });
910
1223
  $effect.pre(() => {
911
1224
  const mode = effectiveMode;
912
- if (mode === preFlipSeenMode) return;
913
- const from = preFlipSeenMode;
914
- preFlipSeenMode = mode;
915
- untrack(() => {
916
- if (from !== 'reading') flipCaret = captureFlipCaret();
917
- // A flip is a blur-class event: a live reveal or composition folds through the existing
918
- // blur choke point. Host chrome is exempt, or a mode toggle blurs a title field mid-edit.
919
- const active = document.activeElement;
920
- if (active instanceof HTMLElement && editorEl?.contains(active) && !isHostChrome(active)) {
921
- active.blur();
922
- // A blur the editor performs announces the selection it drops: the document listener
923
- // only reports a range the browser still anchors in the root, and this one is gone.
924
- events.emit('selectionChange', getSelection());
925
- }
926
- });
1225
+ untrack(() => modeFlip.beforeFlip(mode));
927
1226
  });
928
-
929
- // svelte-ignore state_referenced_locally
930
- let lastEffectiveMode = effectiveMode;
931
1227
  $effect(() => {
932
- const mode = effectiveMode;
933
- if (mode === lastEffectiveMode) return;
934
- lastEffectiveMode = mode;
935
- // Which markers paint just changed, so a side recorded against the old geometry
936
- // no longer names the offset the user meant.
937
- edgeAffinity.reset();
938
- // Hidden markers re-wrap prose, so every measured height is the other mode's. Not paired
939
- // with `widthVersion++`: the flip has blurred, so a rebuild here loses the caret pin.
940
- heightOracle.dropMeasured();
941
- if (mode === 'reading') {
942
- // The gap is an editor-owned caret no DOM blur can reach, so the flip clears it
943
- // rather than each arrival path.
944
- selectionState.clearGapCaret();
945
- } else if (flipCaret) {
946
- const caret = flipCaret;
947
- flipCaret = null;
948
- void (async () => {
949
- // A post-tick focus like every structural op's; the road clamps the saved offset
950
- // into the destination mode's landable range at the door. Yielding to a focused
951
- // text-entry surface keeps the restore from stealing a host field mid-typing.
952
- // The reveal is the bare MOUNT: a flip is a view operation, so it re-seats the
953
- // caret without writing the scrollport the reader chose.
954
- await tick();
955
- if (effectiveMode !== mode || isTextEntrySurface(document.activeElement)) return;
956
- await restoreThroughRevealRoad(caretAt(caret.path, caret.offset), 'mount');
957
- })();
958
- }
959
- events.emit('presentationModeChange', mode);
1228
+ modeFlip.afterFlip(effectiveMode);
960
1229
  });
961
1230
 
962
1231
  // A theme flip invalidates no live edit, so it only has to be announced — for
@@ -1151,22 +1420,14 @@
1151
1420
  // ── Resize invalidation ─────────────────────────────────────────────
1152
1421
 
1153
1422
  // A WIDTH change re-wraps prose, staling every cached height, so the scopes rebuild
1154
- // off this counter; a height-only resize spares the measured cache. ResizeObserver's
1155
- // per-callback batching is the coalescing — no setTimeout/rAF debounce (G4.4).
1423
+ // off this counter; a height-only resize spares the measured cache.
1156
1424
  let widthVersion = $state(0);
1157
1425
  $effect(() => {
1158
- const el = editorEl;
1159
- if (!el) return;
1160
- let lastWidth = el.clientWidth;
1161
- const observer = new ResizeObserver(() => {
1162
- const width = el.clientWidth;
1163
- if (width === lastWidth) return;
1164
- lastWidth = width;
1426
+ if (!editorEl) return;
1427
+ return installWidthWatcher(editorEl, () => {
1165
1428
  heightOracle.dropMeasured();
1166
1429
  widthVersion++;
1167
1430
  });
1168
- observer.observe(el);
1169
- return () => observer.disconnect();
1170
1431
  });
1171
1432
 
1172
1433
  // The slice's own axis, watched on the RESOLVED port: the window's extent comes from the
@@ -1183,117 +1444,51 @@
1183
1444
 
1184
1445
  // ── Type scale ──────────────────────────────────────────────────────
1185
1446
 
1186
- // The width change's sibling: a font-size move puts estimates off several-fold, so a
1187
- // document whose true height clears the activation watermark can fail to window at
1188
- // all. Only the off-window set depends on the ESTIMATE moving. The width observer
1189
- // can't see this (no other box in the root resizes), hence the `1em` probe.
1190
- function applyTypeScale(fontSizePx: number): void {
1191
- const next = fontSizePx / ESTIMATE_BASE_FONT_SIZE;
1192
- // Sub-percent moves are sub-pixel on a line box — not worth a full rebuild.
1193
- if (!(next > 0) || Math.abs(next - typeScale) < 0.01) return;
1194
- typeScale = next;
1195
- heightOracle.dropMeasured();
1196
- widthVersion++;
1197
- }
1447
+ // A font-size move puts the estimates off several-fold, which no other box in the root
1448
+ // reports; the probe's scale rides the width signal, since a rebuild is what it needs.
1198
1449
  $effect(() => {
1199
- const el = typeScaleProbeEl;
1200
- if (!el) return;
1201
- applyTypeScale(el.getBoundingClientRect().height);
1202
- const observer = new ResizeObserver((entries) => {
1203
- const box = entries[0]?.borderBoxSize?.[0];
1204
- applyTypeScale(box ? box.blockSize : el.getBoundingClientRect().height);
1450
+ if (!typeScaleProbeEl) return;
1451
+ return installTypeScaleProbe(typeScaleProbeEl, {
1452
+ getScale: () => typeScale,
1453
+ onScale: (next) => {
1454
+ typeScale = next;
1455
+ heightOracle.dropMeasured();
1456
+ widthVersion++;
1457
+ }
1205
1458
  });
1206
- observer.observe(el);
1207
- return () => observer.disconnect();
1208
1459
  });
1209
1460
 
1210
1461
  // ── Header slot compensation ────────────────────────────────────────
1211
1462
 
1212
- // The header slot's height lives outside the height model, so while the editor owns the
1213
- // correction a growing header would slide the document under the reader. Compensating from
1214
- // the SLOT's own resize is what composes with `correctAnchor` instead of double-correcting;
1215
- // a reveal already holding the scroll outranks it.
1216
1463
  $effect(() => {
1217
1464
  const el = headerEl;
1218
1465
  if (!el || !editorEl) return;
1219
1466
  const port = getScrollport();
1220
1467
  if (!port) return;
1221
- let lastHeight = el.getBoundingClientRect().height;
1222
- const observer = new ResizeObserver((entries) => {
1223
- // Border boxes throughout, seed and fallback alike, so a browser without
1224
- // `borderBoxSize` computes the same delta.
1225
- const box = entries[0]?.borderBoxSize?.[0];
1226
- const height = box ? box.blockSize : el.getBoundingClientRect().height;
1227
- const delta = height - lastHeight;
1228
- lastHeight = height;
1229
- if (delta === 0 || !ownsScrollCorrection() || port.scrollTop() === 0) return;
1230
- if (!topWindowing.revealHoldsScroll()) port.setScrollTop(port.scrollTop() + delta);
1468
+ return installHeaderSlotCompensation({
1469
+ el,
1470
+ port,
1471
+ ownsScrollCorrection,
1472
+ revealHoldsScroll: () => topWindowing.revealHoldsScroll()
1231
1473
  });
1232
- observer.observe(el);
1233
- return () => observer.disconnect();
1234
1474
  });
1235
1475
 
1236
1476
  // ── Focus attribution ───────────────────────────────────────────────
1237
1477
 
1238
- // Drives each windowing scope's per-level pin, so a scroll that pushes the caret
1239
- // off-screen never tears down native focus/IME. Plain `let`, not $state: focusout
1240
- // fires mid-teardown during a structural commit, where a reactive write would trip
1241
- // state_unsafe_mutation.
1242
- let focusedPath: number[] | null = null;
1243
-
1244
- // Marks the block host whose leaf holds the caret; both preview modes' CSS key their
1245
- // focused-block reveal off it. Imperative for focusedPath's teardown-safety reason,
1246
- // and mode-gated so source/reading DOM stays byte-identical.
1247
- let focusedHostEl: HTMLElement | null = null;
1248
- function applyFocusedAttr(): void {
1249
- if (focusedHostEl && isPreviewMode(effectiveMode)) {
1250
- focusedHostEl.setAttribute('data-focused', '');
1251
- } else {
1252
- focusedHostEl?.removeAttribute('data-focused');
1478
+ const focusAttribution = createFocusAttribution({
1479
+ get mode() {
1480
+ return effectiveMode;
1253
1481
  }
1254
- }
1255
- function setFocusedHost(host: HTMLElement | null): void {
1256
- if (focusedHostEl === host) return;
1257
- focusedHostEl?.removeAttribute('data-focused');
1258
- focusedHostEl = host;
1259
- applyFocusedAttr();
1260
- }
1261
- // Entering a preview mode marks the already-focused block, since no re-focus fires.
1482
+ });
1262
1483
  $effect(() => {
1263
1484
  void effectiveMode;
1264
- applyFocusedAttr();
1485
+ focusAttribution.applyForMode();
1265
1486
  });
1266
-
1267
1487
  $effect(() => {
1268
1488
  if (!editorEl) return;
1269
- const root = editorEl;
1270
- const onFocusIn = (e: FocusEvent) => {
1271
- const host = (e.target as Element | null)?.closest('[data-block-path]');
1272
- if (!host || !root.contains(host)) {
1273
- focusedPath = null;
1274
- setFocusedHost(null);
1275
- return;
1276
- }
1277
- setFocusedHost(host as HTMLElement);
1278
- const path = readBlockPath(host);
1279
- focusedPath = path && path.length > 0 ? path : null;
1280
- // G1.33 at the seam every caret door crosses: a door seats a caret by focusing the
1281
- // surface, whoever minted it, so a consumer's own door inherits the guard here.
1282
- const landed = e.target;
1283
- if (landed instanceof HTMLElement) {
1284
- assertInvariant('landable-caret', () =>
1285
- checkLandableCaret(landed, effectiveMode, path ?? [])
1286
- );
1287
- }
1288
- };
1289
- const onFocusOut = (e: FocusEvent) => {
1290
- const next = e.relatedTarget as Node | null;
1291
- if (next && root.contains(next)) return; // moving between blocks — keep the pin
1292
- focusedPath = null;
1293
- setFocusedHost(null);
1294
- };
1295
- return removeAll(onRoot(root, 'focusin', onFocusIn), onRoot(root, 'focusout', onFocusOut));
1489
+ return focusAttribution.install(editorEl);
1296
1490
  });
1491
+
1297
1492
  // ── Top-level windowing ─────────────────────────────────────────────
1298
1493
 
1299
1494
  // Assembled here, after the windowing signals it carries exist; the block
@@ -1308,7 +1503,7 @@
1308
1503
  scrollHost: getScrollHost,
1309
1504
  scrollport: getScrollport,
1310
1505
  blockElLookup: getBlockElByPath,
1311
- focusedPath: () => focusedPath,
1506
+ focusedPath: focusAttribution.getFocusedPath,
1312
1507
  heightOracle,
1313
1508
  correctsScroll: ownsScrollCorrection,
1314
1509
  widthVersion: () => widthVersion,
@@ -1416,6 +1611,11 @@
1416
1611
  * focused surface. A gap caret declines: its proxy is not a block, and a NESTED gap's proxy
1417
1612
  * sits inside its container's host, which must not receive what was aimed at the gap.
1418
1613
  */
1614
+ function blurEditingSurface(root: HTMLElement): void {
1615
+ const active = document.activeElement;
1616
+ if (active instanceof HTMLElement && root.contains(active) && active !== root) active.blur();
1617
+ }
1618
+
1419
1619
  function focusedSurfacePath(): number[] | null {
1420
1620
  if (selectionState.gapCaret) return null;
1421
1621
  const active = document.activeElement;
@@ -1460,10 +1660,10 @@
1460
1660
 
1461
1661
  // The door only resolves the focused surface; every rule (the reading gate, the cross-block
1462
1662
  // range decline, the arms themselves) lives below the seam. See `editor-props.ts`.
1463
- export function runCommand(commandId: string): boolean {
1663
+ export function runCommand(commandId: string, arg?: unknown): boolean {
1464
1664
  return runCommandById(
1465
1665
  commandId as AnyCommandId,
1466
- undefined,
1666
+ arg,
1467
1667
  focusedCommandTarget(),
1468
1668
  commandDispatchContext,
1469
1669
  commandErrorSink
@@ -1632,6 +1832,7 @@
1632
1832
  tabindex="-1"
1633
1833
  role="group"
1634
1834
  aria-label={EDITOR_LABEL}
1835
+ oncontextmenu={onRootContextMenu}
1635
1836
  >
1636
1837
  {#if searchBar}
1637
1838
  <!-- Zero-height sticky anchor, so the bar doesn't scroll away with content. Portaled
@@ -1665,6 +1866,38 @@
1665
1866
  window={topWindowing.window}
1666
1867
  reorderable={true}
1667
1868
  />
1869
+ <!-- A sibling of the list like the header: the windowing scope wants the list bare. -->
1870
+ <TailInsert
1871
+ {blockEdit}
1872
+ childCount={doc.children.length}
1873
+ readOnly={effectiveMode === 'reading'}
1874
+ onPlus={(button) => void onTailPlus(button)}
1875
+ />
1876
+ {#if blockMenu}
1877
+ <BlockMenu
1878
+ x={blockMenu.x}
1879
+ y={blockMenu.y}
1880
+ anchor={blockMenu.anchor}
1881
+ items={blockMenu.items}
1882
+ label={blockMenu.label}
1883
+ onPick={blockMenu.pick}
1884
+ onClose={() => (blockMenu = null)}
1885
+ />
1886
+ {/if}
1887
+ {#if selectionToolbar && effectiveMode !== 'reading'}
1888
+ <SelectionToolbar
1889
+ editor={{
1890
+ getSelection,
1891
+ getRects,
1892
+ getBlockKindAt,
1893
+ runCommand,
1894
+ canRunCommand,
1895
+ isCommandActive,
1896
+ getEvents
1897
+ }}
1898
+ root={editorEl}
1899
+ />
1900
+ {/if}
1668
1901
  <ImageOverlayHost
1669
1902
  {widgetSelection}
1670
1903
  {controller}
@@ -1709,7 +1942,9 @@
1709
1942
  .editor {
1710
1943
  width: 100%;
1711
1944
  flex: 1;
1712
- padding: 1rem;
1945
+ /* Wider on the left: the drag grip lives in that gutter, 1.25rem out from the content,
1946
+ and the rest of this padding is what keeps it off the scroll container's border. */
1947
+ padding: 1rem 1rem 1rem 1.5rem;
1713
1948
  font-family: var(--font-editor, ui-monospace, monospace);
1714
1949
  /* The type-scale root: every construct sizes in `em` off this, so one
1715
1950
  declaration scales the whole surface. */
@@ -1724,9 +1959,10 @@
1724
1959
  double-corrects. Do NOT restore `overflow-anchor` (VR-2). */
1725
1960
  overflow-anchor: none;
1726
1961
  scrollbar-width: thin;
1727
- scrollbar-color: var(--color-ui-muted, #a4a4a4) transparent;
1728
- border: 1px solid var(--color-ui-muted, #a4a4a4);
1729
- border-radius: 4px;
1962
+ scrollbar-color: var(--color-border, #3e3e3b) transparent;
1963
+ /* No box of its own. A document is the page's content, not a widget sitting on it, and
1964
+ an outline around the whole editor reads as a form field the moment the host gives it
1965
+ a column. A host that wants the frame draws it on its own container. */
1730
1966
  /* Containing block for the image overlay portal. */
1731
1967
  position: relative;
1732
1968
  }