@voithos-labs/aragonite 0.10.2 → 0.10.3

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 (255) hide show
  1. package/README.md +6 -21
  2. package/THIRD-PARTY-NOTICES.md +25 -0
  3. package/dist/a11y-strings.d.ts +18 -0
  4. package/dist/a11y-strings.js +18 -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 +35 -24
  9. package/dist/components/BlockHost.svelte +16 -9
  10. package/dist/components/Editor.svelte +381 -198
  11. package/dist/components/Editor.svelte.d.ts +1 -1
  12. package/dist/components/SelectionOverlay.svelte +17 -3
  13. package/dist/components/TailInsert.svelte +107 -0
  14. package/dist/components/TailInsert.svelte.d.ts +17 -0
  15. package/dist/components/block-content-selector.d.ts +6 -2
  16. package/dist/components/block-content-selector.js +6 -2
  17. package/dist/components/blocks/ThematicBreakBlock.svelte +19 -6
  18. package/dist/components/blocks/ThematicBreakBlock.svelte.d.ts +1 -0
  19. package/dist/components/blocks/code/CodeBlock.svelte +211 -30
  20. package/dist/components/blocks/code/CodeBlockRail.svelte +686 -0
  21. package/dist/components/blocks/code/CodeBlockRail.svelte.d.ts +26 -0
  22. package/dist/components/blocks/code/code-bootstrap.js +4 -0
  23. package/dist/components/blocks/code/code-context-actions.d.ts +1 -0
  24. package/dist/components/blocks/code/code-context-actions.js +24 -0
  25. package/dist/components/blocks/code/code-fence-exit.d.ts +15 -0
  26. package/dist/components/blocks/code/code-fence-exit.js +26 -0
  27. package/dist/components/blocks/code/code-languages.d.ts +6 -0
  28. package/dist/components/blocks/code/code-languages.js +11 -0
  29. package/dist/components/blocks/code/code-renderer.js +11 -0
  30. package/dist/components/blocks/directive/DirectiveContainerBlock.svelte +1 -1
  31. package/dist/components/blocks/editable-leaf.d.ts +37 -6
  32. package/dist/components/blocks/editable-leaf.js +243 -29
  33. package/dist/components/blocks/editable-surface.d.ts +9 -0
  34. package/dist/components/blocks/editable-surface.js +22 -3
  35. package/dist/components/blocks/list/ListItemBlock.svelte +3 -3
  36. package/dist/components/blocks/list/task-checkbox.d.ts +2 -0
  37. package/dist/components/blocks/list/task-checkbox.js +11 -2
  38. package/dist/components/blocks/surface-wiring.svelte.d.ts +4 -0
  39. package/dist/components/blocks/surface-wiring.svelte.js +8 -1
  40. package/dist/components/blocks/table/TableActionMenu.svelte +210 -86
  41. package/dist/components/blocks/table/TableActionMenu.svelte.d.ts +5 -0
  42. package/dist/components/blocks/table/TableBlock.svelte +205 -186
  43. package/dist/components/blocks/table/TableBlock.svelte.d.ts +1 -0
  44. package/dist/components/blocks/table/TableCellBlock.svelte +87 -22
  45. package/dist/components/blocks/table/TableRowBlock.svelte +4 -18
  46. package/dist/components/blocks/table/TableRowBlock.svelte.d.ts +0 -5
  47. package/dist/components/blocks/table/cell-clipboard.d.ts +10 -0
  48. package/dist/components/blocks/table/cell-clipboard.js +34 -1
  49. package/dist/components/blocks/table/cell-keydown-plan.d.ts +1 -1
  50. package/dist/components/blocks/table/cell-keydown-plan.js +3 -1
  51. package/dist/components/blocks/table/table-cell-paste.js +2 -1
  52. package/dist/components/blocks/table/table-menu-model.d.ts +35 -8
  53. package/dist/components/blocks/table/table-menu-model.js +36 -17
  54. package/dist/components/blocks/text/TextEditableBlock.svelte +58 -11
  55. package/dist/components/blocks/text/delimiter-autopair.d.ts +71 -0
  56. package/dist/components/blocks/text/delimiter-autopair.js +216 -0
  57. package/dist/components/blocks/text/edge-policy-dispatch.d.ts +4 -0
  58. package/dist/components/blocks/text/edge-policy-dispatch.js +56 -2
  59. package/dist/components/blocks/text/live-selection-edit.js +27 -0
  60. package/dist/components/blocks/text/text-keydown.d.ts +7 -1
  61. package/dist/components/blocks/text/text-keydown.js +11 -1
  62. package/dist/components/blocks/text/text-render.d.ts +1 -1
  63. package/dist/components/blocks/text/text-render.js +3 -1
  64. package/dist/components/blocks/text/widget-interaction.d.ts +3 -0
  65. package/dist/components/blocks/text/widget-interaction.js +144 -22
  66. package/dist/components/drag-handle.d.ts +35 -0
  67. package/dist/components/drag-handle.js +126 -0
  68. package/dist/components/editor-root-focus.d.ts +19 -0
  69. package/dist/components/editor-root-focus.js +67 -0
  70. package/dist/components/editor-root-geometry.d.ts +39 -0
  71. package/dist/components/editor-root-geometry.js +91 -0
  72. package/dist/components/editor-root-keydown.d.ts +1 -1
  73. package/dist/components/editor-root-keydown.js +12 -3
  74. package/dist/components/editor-root-listeners.d.ts +10 -6
  75. package/dist/components/editor-root-listeners.js +23 -22
  76. package/dist/components/editor-root-mode-flip.d.ts +36 -0
  77. package/dist/components/editor-root-mode-flip.js +92 -0
  78. package/dist/components/image/ImageOverlayHost.svelte +14 -6
  79. package/dist/components/image/ImageProperties.svelte +512 -70
  80. package/dist/components/image/ImageProperties.svelte.d.ts +6 -1
  81. package/dist/components/image/ImageResizeHandles.svelte +59 -34
  82. package/dist/components/image/image-crop.d.ts +39 -0
  83. package/dist/components/image/image-crop.js +74 -0
  84. package/dist/components/image/image-edit-commit.d.ts +1 -0
  85. package/dist/components/image/image-edit-commit.js +21 -5
  86. package/dist/components/image/image-source-bytes.js +10 -3
  87. package/dist/components/image/image-widget-editing.js +1 -0
  88. package/dist/components/image/widget-dom.js +5 -1
  89. package/dist/components/link-card/link-card-commit.js +1 -1
  90. package/dist/components/lrd-map-gate.js +1 -1
  91. package/dist/components/menu/BlockMenu.svelte +315 -0
  92. package/dist/components/menu/BlockMenu.svelte.d.ts +34 -0
  93. package/dist/components/menu/MenuIcon.svelte +153 -0
  94. package/dist/components/menu/MenuIcon.svelte.d.ts +51 -0
  95. package/dist/components/menu/clipboard-actions.d.ts +12 -0
  96. package/dist/components/menu/clipboard-actions.js +42 -0
  97. package/dist/components/menu/default-context-actions.d.ts +15 -0
  98. package/dist/components/menu/default-context-actions.js +76 -0
  99. package/dist/components/menu/flyout-placement.d.ts +6 -0
  100. package/dist/components/menu/flyout-placement.js +25 -0
  101. package/dist/core/inline/format-toggle.d.ts +12 -4
  102. package/dist/core/inline/format-toggle.js +94 -40
  103. package/dist/core/inline/image-dimensions.d.ts +3 -0
  104. package/dist/core/inline/image-dimensions.js +46 -10
  105. package/dist/core/inline/inline-widgets.d.ts +11 -0
  106. package/dist/core/inline/scan/brackets.js +1 -0
  107. package/dist/core/inline/scan/plugin-syntax.d.ts +8 -0
  108. package/dist/core/inline/scan/plugin-syntax.js +15 -1
  109. package/dist/core/inline-render.d.ts +6 -0
  110. package/dist/core/inline-render.js +32 -0
  111. package/dist/core/nodes.d.ts +14 -0
  112. package/dist/cursor/edge-affinity.js +2 -1
  113. package/dist/cursor/overlay-remeasure.js +8 -0
  114. package/dist/cursor/reveal-source.js +7 -2
  115. package/dist/cursor/widget-offset.d.ts +6 -0
  116. package/dist/cursor/widget-offset.js +61 -4
  117. package/dist/debug/interaction-trace.d.ts +4 -0
  118. package/dist/debug/interaction-trace.js +15 -0
  119. package/dist/decorations/decoration-state.svelte.js +1 -1
  120. package/dist/editor-actions/ancestry-folds.d.ts +2 -2
  121. package/dist/editor-actions/ancestry-folds.js +1 -1
  122. package/dist/editor-actions/block-edit-scope.js +1 -1
  123. package/dist/editor-actions/commit/text-batch.d.ts +3 -2
  124. package/dist/editor-actions/commit/text-batch.js +1 -1
  125. package/dist/editor-actions/commit/undo-controller.js +4 -2
  126. package/dist/editor-actions/container-edit.js +2 -1
  127. package/dist/editor-actions/enter-completion.d.ts +2 -0
  128. package/dist/editor-actions/enter-completion.js +23 -2
  129. package/dist/editor-actions/inline-range-commit.js +1 -1
  130. package/dist/editor-actions/reorder-action.js +19 -10
  131. package/dist/editor-actions/reorder-drag.js +29 -1
  132. package/dist/editor-actions/replacement-focus.d.ts +1 -1
  133. package/dist/editor-actions/replacement-focus.js +1 -1
  134. package/dist/editor-actions/search-replace.js +1 -1
  135. package/dist/editor-actions/table-context.d.ts +4 -1
  136. package/dist/editor-actions/table-context.js +57 -1
  137. package/dist/editor-events.d.ts +3 -0
  138. package/dist/editor-keys.d.ts +33 -0
  139. package/dist/editor-props.d.ts +21 -12
  140. package/dist/index.d.ts +1 -1
  141. package/dist/plugin.d.ts +6 -0
  142. package/dist/plugin.js +11 -0
  143. package/dist/plugins/latex/BlockMath.svelte +264 -29
  144. package/dist/plugins/latex/BlockMath.svelte.d.ts +2 -0
  145. package/dist/plugins/latex/index.d.ts +2 -1
  146. package/dist/plugins/latex/latex-kind.js +36 -3
  147. package/dist/plugins/latex/math-completion.js +4 -1
  148. package/dist/plugins/latex/math-layout.d.ts +15 -0
  149. package/dist/plugins/latex/math-layout.js +13 -0
  150. package/dist/plugins/latex/math-source.d.ts +19 -0
  151. package/dist/plugins/latex/math-source.js +97 -0
  152. package/dist/plugins/latex/register.d.ts +11 -2
  153. package/dist/plugins/latex/register.js +3 -1
  154. package/dist/plugins/latex/renderer.d.ts +3 -3
  155. package/dist/plugins/latex/renderer.js +13 -6
  156. package/dist/reactivity/list-windowing.svelte.d.ts +8 -8
  157. package/dist/reactivity/list-windowing.svelte.js +49 -24
  158. package/dist/schema/block-completions.d.ts +8 -0
  159. package/dist/schema/block-completions.js +11 -0
  160. package/dist/schema/context-actions.d.ts +31 -0
  161. package/dist/schema/context-actions.js +23 -0
  162. package/dist/schema/fenced-code-raw.js +31 -1
  163. package/dist/schema/operations.d.ts +8 -1
  164. package/dist/schema/reserved-chords.js +25 -4
  165. package/dist/schema/table-cell-raw.d.ts +1 -1
  166. package/dist/schema/table-cell-raw.js +1 -1
  167. package/dist/selection/block-hit-test.js +3 -2
  168. package/dist/selection/char-endpoint-snap.js +1 -1
  169. package/dist/selection/clipboard-text.js +6 -1
  170. package/dist/selection/covered-block.d.ts +10 -0
  171. package/dist/selection/covered-block.js +24 -0
  172. package/dist/selection/cross-block/dispatch.d.ts +3 -0
  173. package/dist/selection/cross-block/dispatch.js +13 -1
  174. package/dist/selection/cross-block/format-range.d.ts +1 -1
  175. package/dist/selection/cross-block/format-range.js +4 -15
  176. package/dist/selection/cross-block/format-toggle.js +1 -1
  177. package/dist/selection/cross-block/keydown.js +1 -1
  178. package/dist/selection/cross-block/ops.js +1 -1
  179. package/dist/selection/cross-block/paste.js +32 -30
  180. package/dist/selection/cross-block/type-replace.d.ts +3 -2
  181. package/dist/selection/cross-block/type-replace.js +62 -13
  182. package/dist/selection/dead-space-caret.d.ts +10 -0
  183. package/dist/selection/dead-space-caret.js +47 -1
  184. package/dist/selection/double-click-trim.d.ts +17 -0
  185. package/dist/selection/double-click-trim.js +57 -0
  186. package/dist/selection/drag-pointer.d.ts +7 -2
  187. package/dist/selection/drag-pointer.js +61 -2
  188. package/dist/selection/gap-caret.js +1 -1
  189. package/dist/selection/keyboard-extend.js +1 -1
  190. package/dist/selection/path-lookup.js +1 -1
  191. package/dist/selection/range-delete-ceremony.js +4 -2
  192. package/dist/selection/range-delete-chrome.js +2 -1
  193. package/dist/selection/range-delete-table-coverage.js +2 -1
  194. package/dist/selection/range-delete-table.js +3 -2
  195. package/dist/selection/range-delete.js +30 -2
  196. package/dist/selection/selection-restore.js +1 -1
  197. package/dist/selection/selection-state.svelte.d.ts +6 -0
  198. package/dist/selection/selection-state.svelte.js +36 -1
  199. package/dist/selection/table-endpoint-snap.js +1 -1
  200. package/dist/selection/table-rect-extend.js +1 -1
  201. package/dist/styles/editor-theme.css +57 -28
  202. package/dist/styles/editor.css +217 -18
  203. package/dist/testing/container-conformance.js +2 -2
  204. package/dist/testing/inline-conformance.js +2 -1
  205. package/dist/tree-operations/blockquote.js +1 -1
  206. package/dist/tree-operations/chain-rebuild.d.ts +63 -0
  207. package/dist/tree-operations/chain-rebuild.js +142 -0
  208. package/dist/tree-operations/children.d.ts +1 -1
  209. package/dist/tree-operations/children.js +1 -1
  210. package/dist/tree-operations/cleanup.js +1 -1
  211. package/dist/tree-operations/content-write.d.ts +50 -0
  212. package/dist/tree-operations/content-write.js +263 -0
  213. package/dist/tree-operations/index.d.ts +8 -3
  214. package/dist/tree-operations/index.js +6 -2
  215. package/dist/tree-operations/list/exit-replacement.js +1 -1
  216. package/dist/tree-operations/list/unwrap-merge.js +3 -3
  217. package/dist/tree-operations/node-ops.d.ts +17 -234
  218. package/dist/tree-operations/node-ops.js +47 -1113
  219. package/dist/tree-operations/node-primitives.d.ts +75 -0
  220. package/dist/tree-operations/node-primitives.js +117 -0
  221. package/dist/tree-operations/paste/apply.js +1 -1
  222. package/dist/tree-operations/paste/body-write.d.ts +1 -1
  223. package/dist/tree-operations/paste/body-write.js +2 -2
  224. package/dist/tree-operations/paste/container-match.js +4 -2
  225. package/dist/tree-operations/paste/dispatch.js +2 -1
  226. package/dist/tree-operations/paste/find-enclosing-list.js +1 -1
  227. package/dist/tree-operations/paste/focus-target.d.ts +1 -1
  228. package/dist/tree-operations/paste/list-absorb.js +1 -1
  229. package/dist/tree-operations/paste/list-break-out.js +1 -1
  230. package/dist/tree-operations/paste/parent-scope.js +1 -1
  231. package/dist/tree-operations/paste/paste-replacement.js +1 -1
  232. package/dist/tree-operations/paste/replace-block-at-parent.js +1 -1
  233. package/dist/tree-operations/path-mutate.d.ts +1 -1
  234. package/dist/tree-operations/path-mutate.js +2 -1
  235. package/dist/tree-operations/reorder-unit.js +1 -1
  236. package/dist/tree-operations/reorder.d.ts +5 -2
  237. package/dist/tree-operations/reorder.js +55 -2
  238. package/dist/tree-operations/settle.d.ts +105 -0
  239. package/dist/tree-operations/settle.js +660 -0
  240. package/dist/tree-operations/table-grid-clipboard.d.ts +21 -0
  241. package/dist/tree-operations/table-grid-clipboard.js +90 -0
  242. package/dist/tree-operations/unshare.d.ts +15 -77
  243. package/dist/tree-operations/unshare.js +15 -166
  244. package/docs/guide/consumer-guide.md +126 -96
  245. package/docs/guide/plugin-api.md +31 -3
  246. package/docs/guide/plugin-guide.md +26 -3
  247. package/package.json +4 -2
  248. package/dist/components/blocks/code/CodeLanguageChip.svelte +0 -127
  249. package/dist/components/blocks/code/CodeLanguageChip.svelte.d.ts +0 -14
  250. package/dist/components/blocks/table/TableGrip.svelte +0 -91
  251. package/dist/components/blocks/table/TableGrip.svelte.d.ts +0 -8
  252. package/dist/components/blocks/table/table-drop-target.d.ts +0 -1
  253. package/dist/components/blocks/table/table-drop-target.js +0 -16
  254. package/dist/components/blocks/table/table-reorder-drag.d.ts +0 -78
  255. 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,34 @@
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';
40
41
  import { createContentVersion } from '../reactivity/content-version.svelte';
41
42
  import { useContainerWindowing } from '../reactivity/use-container-windowing.svelte';
42
43
  import { refSlotsOver, replaceRefs, revealChildOrWait } from '../reactivity/publish-ref.svelte';
43
44
  import { createSelectionState } from '../selection/selection-state.svelte';
44
45
  import { createSelectionDescription } from '../selection/selection-description';
45
- import { EDITOR_LABEL, movedBlockToPosition } from '../a11y-strings';
46
+ import {
47
+ BLOCK_ACTIONS_LABEL,
48
+ BLOCK_MENU_LABEL,
49
+ EDITOR_LABEL,
50
+ movedBlockToPosition
51
+ } from '../a11y-strings';
52
+ import TailInsert from './TailInsert.svelte';
53
+ import BlockMenu, {
54
+ insertFlyoutEntries,
55
+ insertMenuEntries,
56
+ insertSnippets,
57
+ type MenuEntry
58
+ } from './menu/BlockMenu.svelte';
59
+ import { runClipboardAction, type ClipboardAction } from './menu/clipboard-actions';
60
+ import { blockContextActionsFor, type BlockContextAction } from '../schema/context-actions';
61
+ import { isProseBackground, registerDefaultContextActions } from './menu/default-context-actions';
46
62
  import type { EditorSelection } from '../selection/primitives';
47
63
  import { createWidgetSelectionState } from './image/widget-selection-state.svelte';
48
64
  import { bootstrapCodeLanguages } from './blocks/code/code-bootstrap';
49
65
  import { assignIds } from '../block-id';
50
66
  import { ensureEditableContainers, emptyParagraph } from '../tree-operations';
51
- import { blockNodeAt, isBlockNode, nodeAt } from '../tree-operations/node-ops';
67
+ import { blockNodeAt, isBlockNode, nodeAt } from '../tree-operations/node-primitives';
52
68
  import { serialize } from '../core/serializer';
53
69
  import { parse } from '../core/parser';
54
70
  import { defaultLinkActivation } from '../core/url-policy';
@@ -80,19 +96,26 @@
80
96
  } from '../debug/interaction-trace';
81
97
  import { readCurrentSelection } from '../selection/native-bridge';
82
98
  import { restoreSelection, type SelectionRestoreOutcome } from '../selection/selection-restore';
83
- import { findSurfacePathForElement, readBlockPath } from '../selection/path-lookup';
99
+ import { findSurfacePathForElement } from '../selection/path-lookup';
84
100
  import { createCaretRestore } from '../selection/caret-restore';
85
101
  import { createCrossBlockHandlers } from '../selection/cross-block/dispatch';
86
102
  import { createCrossBlockCommands } from '../selection/cross-block/format-toggle';
87
- import { isPreviewMode } from '../presentation-mode';
88
103
  import { normalizeKeybindingOverrides } from '../schema/keybinding-overrides';
89
104
  import { createEditorRootKeydown } from './editor-root-keydown';
90
105
  import { createEditorRootClipboard } from './editor-root-clipboard';
106
+ import { createModeFlip } from './editor-root-mode-flip';
107
+ import { createFocusAttribution } from './editor-root-focus';
108
+ import {
109
+ installHeaderSlotCompensation,
110
+ installTypeScaleProbe,
111
+ installViewportHeightWatcher,
112
+ installWidthWatcher
113
+ } from './editor-root-geometry';
91
114
  import {
115
+ installDoubleClickWordSelect,
92
116
  installEditorBlurAnnouncer,
93
117
  installModActiveTracker,
94
118
  installSelectionChangeBridge,
95
- installViewportHeightWatcher,
96
119
  onRoot,
97
120
  removeAll
98
121
  } from './editor-root-listeners';
@@ -102,12 +125,8 @@
102
125
  registerEditor,
103
126
  unregisterEditor,
104
127
  markEditorInteracted,
105
- releaseInteractedEditor,
106
- isTextEntrySurface
128
+ releaseInteractedEditor
107
129
  } 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
130
  import {
112
131
  canRunCommandById,
113
132
  isCommandActiveById,
@@ -134,12 +153,12 @@
134
153
  import { runStartupInvariantChecks } from '../invariants/install';
135
154
  import { assertInvariant } from '../assert';
136
155
  import { checkMarkerCssParity } from '../invariants/marker-css-parity';
137
- import { checkLandableCaret } from '../invariants/landable-caret';
138
156
  import { registerBuiltInBlocks } from './built-in-blocks';
139
157
  import { BLOCK_CONTENT_SELECTOR } from './block-content-selector';
140
158
 
141
159
  registerBuiltInBlocks();
142
160
  bootstrapCodeLanguages();
161
+ registerDefaultContextActions();
143
162
  runStartupInvariantChecks();
144
163
 
145
164
  // `__registryEnablement` is a harness-only door; the intersection keeps it off the
@@ -151,8 +170,10 @@
151
170
  imageLoadPolicy = 'auto',
152
171
  onLinkActivate,
153
172
  onPasteImage,
173
+ onRunCode,
174
+ codeMenuItems,
154
175
  header,
155
- blockDragHandles = false,
176
+ blockDragHandles = true,
156
177
  searchBar = true,
157
178
  searchBarAnchor,
158
179
  keybindings,
@@ -447,6 +468,189 @@
447
468
  revealBlock: (index) => revealPath([index])
448
469
  });
449
470
 
471
+ // ── Block menu ──────────────────────────────────────────────────────
472
+
473
+ // Opened by the bottom `+` and by a right-click on prose with nothing selected; a right-click
474
+ // over a selection leaves the formatting popover (the host's) in charge and only suppresses
475
+ // the native menu. Tables run their own cell menu and have prevented the default first.
476
+ let blockMenu = $state<{
477
+ x: number;
478
+ y: number;
479
+ anchor: () => { x: number; y: number };
480
+ items: MenuEntry[];
481
+ label: string;
482
+ pick: (id: string) => void;
483
+ } | null>(null);
484
+
485
+ // Open/close transitions only, never the mount, so a subscriber's first news is a real menu.
486
+ let menuWasOpen = false;
487
+ $effect(() => {
488
+ const open = blockMenu !== null;
489
+ if (open === menuWasOpen) return;
490
+ menuWasOpen = open;
491
+ events.emit('menuChange', open);
492
+ });
493
+
494
+ function anchorOn(el: Element, point: { x: number; y: number }): () => { x: number; y: number } {
495
+ const rect = el.getBoundingClientRect();
496
+ const dx = point.x - rect.left;
497
+ const dy = point.y - rect.top;
498
+ return () => {
499
+ const now = el.getBoundingClientRect();
500
+ return { x: now.left + dx, y: now.top + dy };
501
+ };
502
+ }
503
+
504
+ // A right-click on a BLOCK — a fence, an equation, an image — is that block's context menu:
505
+ // its kind's registered actions, then the defaults. Prose is the page's background and keeps
506
+ // the browser's own menu (spelling, the platform's clipboard), as does a selection, whose
507
+ // affordance is the host's formatting popover. The margin shows nothing at all.
508
+ function onRootContextMenu(e: MouseEvent): void {
509
+ if (e.defaultPrevented || effectiveMode === 'reading' || !editorEl) return;
510
+ const target = e.target instanceof Element ? e.target : null;
511
+ if (!target || isHostChrome(target)) return;
512
+ e.preventDefault();
513
+ // The keyboard's contextmenu event (Windows fires it on the ContextMenu key's release)
514
+ // lands on the item the keydown's own menu already focused: that menu is the answer.
515
+ if (target.closest('.md-menu')) return;
516
+ const host = target.closest<HTMLElement>('.block-host[data-block-path]');
517
+ const path = pathOf(host);
518
+ if (!host || !path) return;
519
+ const point = { x: e.clientX, y: e.clientY };
520
+ const native = window.getSelection();
521
+ const selected = !!native && !native.isCollapsed && editorEl.contains(native.anchorNode);
522
+ // A selection's menu is the clipboard's, over the selection as it stands. So is prose's
523
+ // (the page's background), at a caret placed where the press landed; a nested block (a
524
+ // fence inside a list item) takes the same for now.
525
+ const node = path.length === 1 ? doc.children[path[0]] : undefined;
526
+ if (selected || !node || isProseBackground(node)) {
527
+ if (!selected) placeCaretAtPoint(e.clientX, e.clientY);
528
+ // Top-level prose is where a sibling block makes sense; a nested block or a selection
529
+ // gets the clipboard alone.
530
+ const insertAfter = !selected && node && isProseBackground(node) ? path[0] : null;
531
+ openClipboardMenu(point, host, insertAfter);
532
+ return;
533
+ }
534
+ const index = path[0];
535
+ const actions = blockContextActionsFor(node, path);
536
+ if (actions.length === 0) return;
537
+ const ctx = {
538
+ node,
539
+ path,
540
+ deleteBlock: async () => {
541
+ await blockEdit.deleteBlock(index);
542
+ },
543
+ replaceRaw: async (raw: string) => {
544
+ await blockEdit.updateBlockContent(index, raw);
545
+ }
546
+ };
547
+ blockMenu = {
548
+ ...point,
549
+ anchor: anchorOn(host, point),
550
+ items: actions.map(({ id, label, icon, danger }) => ({
551
+ id,
552
+ label,
553
+ icon: icon as MenuEntry['icon'],
554
+ danger
555
+ })),
556
+ label: BLOCK_ACTIONS_LABEL,
557
+ pick: (id) => {
558
+ blockMenu = null;
559
+ const action = actions.find((a: BlockContextAction) => a.id === id);
560
+ if (action) void action.run(ctx);
561
+ }
562
+ };
563
+ }
564
+
565
+ // The editing surface a paste goes to: whatever editable holds focus inside the root.
566
+ function focusedEditable(): HTMLElement | null {
567
+ const active = document.activeElement;
568
+ return active instanceof HTMLElement && editorEl?.contains(active) && active.isContentEditable
569
+ ? active
570
+ : null;
571
+ }
572
+
573
+ function clipboardRows(): MenuEntry[] {
574
+ const selected = !!focusedEditable() && !(window.getSelection()?.isCollapsed ?? true);
575
+ return [
576
+ { id: 'clip.cut', label: 'Cut', icon: 'scissors', disabled: !selected },
577
+ { id: 'clip.copy', label: 'Copy', icon: 'copy', disabled: !selected },
578
+ { id: 'clip.paste', label: 'Paste', icon: 'clipboard' },
579
+ { id: 'clip.paste-plain', label: 'Paste as plain text', icon: 'type' }
580
+ ];
581
+ }
582
+
583
+ function runClipboardRow(id: string): boolean {
584
+ if (!id.startsWith('clip.')) return false;
585
+ void runClipboardAction(id.slice('clip.'.length) as ClipboardAction, focusedEditable());
586
+ return true;
587
+ }
588
+
589
+ /** `insertAfter` names the top-level block an "Insert block" flyout mints an empty sibling
590
+ * after; null leaves the menu to the clipboard rows alone. */
591
+ function openClipboardMenu(
592
+ point: { x: number; y: number },
593
+ anchorEl: Element,
594
+ insertAfter: number | null = null
595
+ ): void {
596
+ const insert: MenuEntry[] =
597
+ insertAfter === null
598
+ ? []
599
+ : [
600
+ { id: 'sep', label: '', divider: true },
601
+ { id: 'insert', label: 'Insert block', icon: 'plus', children: insertFlyoutEntries() }
602
+ ];
603
+ blockMenu = {
604
+ ...point,
605
+ anchor: anchorOn(anchorEl, point),
606
+ items: [...clipboardRows(), ...insert],
607
+ label: BLOCK_ACTIONS_LABEL,
608
+ pick: (id) => {
609
+ blockMenu = null;
610
+ if (runClipboardRow(id)) return;
611
+ const md = insertSnippets().get(id);
612
+ if (md && insertAfter !== null) void insertBlockAfter(insertAfter, md);
613
+ }
614
+ };
615
+ }
616
+
617
+ // The same two steps the tail's `+` takes: mint the empty paragraph, which lands the caret in
618
+ // it, then hand the snippet to the surface that now holds focus.
619
+ async function insertBlockAfter(index: number, md: string): Promise<void> {
620
+ await blockEdit.insertParagraph(index + 1, '');
621
+ insertMarkdown(md);
622
+ }
623
+
624
+ function pathOf(host: HTMLElement | null): number[] | null {
625
+ const raw = host?.dataset.blockPath;
626
+ if (!raw) return null;
627
+ try {
628
+ const parsed: unknown = JSON.parse(raw);
629
+ return Array.isArray(parsed) && parsed.every((n) => typeof n === 'number') ? parsed : null;
630
+ } catch {
631
+ return null;
632
+ }
633
+ }
634
+
635
+ async function onTailPlus(button: HTMLElement): Promise<void> {
636
+ await blockEdit.insertParagraph(doc.children.length, '');
637
+ const rect = button.getBoundingClientRect();
638
+ const point = { x: rect.left, y: rect.bottom + 4 };
639
+ const snippets = insertSnippets();
640
+ blockMenu = {
641
+ ...point,
642
+ anchor: anchorOn(button, point),
643
+ items: [...insertMenuEntries(), { id: 'sep', label: '', divider: true }, ...clipboardRows()],
644
+ label: BLOCK_MENU_LABEL,
645
+ pick: (id) => {
646
+ blockMenu = null;
647
+ if (runClipboardRow(id)) return;
648
+ const md = snippets.get(id);
649
+ if (md) insertMarkdown(md);
650
+ }
651
+ };
652
+ }
653
+
450
654
  // ── Link card door ──────────────────────────────────────────────────
451
655
 
452
656
  // The caret snapshot and the entry guards ride the STATE, not the callers, so entry path N+1
@@ -509,9 +713,27 @@
509
713
  }
510
714
  const anchor = target?.closest('a[href]') as HTMLAnchorElement | null;
511
715
  // The helper claims only clicks whose target IS the root, so nothing the
512
- // editor renders is touched.
716
+ // editor renders is touched. A margin drag's release arrives as a root click too (the
717
+ // press and release targets meet at the root); with a range just painted, it is no
718
+ // click to answer.
513
719
  if (!anchor) {
514
- deadSpaceCaret.handleClick(root, e);
720
+ const pressed = marginDrag;
721
+ const dragged =
722
+ pressed &&
723
+ (Math.abs(e.clientX - marginDown.x) > 3 || Math.abs(e.clientY - marginDown.y) > 3);
724
+ marginDrag = false;
725
+ if (dragged) return;
726
+ if (deadSpaceCaret.handleClick(root, e)) return;
727
+ // A press the editor took on a block (a host's own padding beside a table, a rule, a
728
+ // folded equation's face) that did not move is a click on it: the click helper
729
+ // claims only dead space, so the same landing is resolved here.
730
+ if (pressed && !deadSpaceCaret.isDeadSpaceTarget(root, e.target)) {
731
+ if (placeCaretAtPoint(e.clientX, e.clientY)) return;
732
+ }
733
+ // Declined everywhere: a click on nothing still LEAVES what was being edited (a
734
+ // revealed equation folds on blur), since the margin press suppressed the blur the
735
+ // browser would have done.
736
+ if (pressed) blurEditingSurface(root);
515
737
  return;
516
738
  }
517
739
  // Host chrome follows the page's link behaviour, not plain-click-edits.
@@ -529,9 +751,62 @@
529
751
  e.preventDefault();
530
752
  }
531
753
  };
754
+ // Dead space, and the parts of a block that are neither editable nor controls: a rendered
755
+ // equation, a diagram, a card's rendered face. A press on any of them cannot grow a native
756
+ // selection, so the editor's drag runs from it.
757
+ // A whole-block input proxy is an editable in name only (it catches IME for a block with no
758
+ // text), so a press on it starts the editor's drag like a press on the block itself.
759
+ const NOT_A_DRAG_START =
760
+ '[contenteditable="true"]:not([data-whole-block-input]), button, input, textarea, select, ' +
761
+ 'a, summary, [role="checkbox"], ' +
762
+ '.code-rail, .table-add-zone, .editor-tail, .md-menu, .block-drag-handle';
763
+ const dragStartsHere = (rootEl: HTMLElement, target: EventTarget | null): boolean => {
764
+ if (deadSpaceCaret.isDeadSpaceTarget(rootEl, target)) return true;
765
+ if (!(target instanceof Element) || !rootEl.contains(target)) return false;
766
+ return target.closest(NOT_A_DRAG_START) === null;
767
+ };
768
+ // A drag that STARTS in the margin: the browser cannot grow a selection from a
769
+ // non-editable press into an editable block, so the editor runs the drag itself, anchored
770
+ // where a click there would land. The mousedown's default is suppressed so the browser
771
+ // starts no selection of its own to fight it; `click` still fires for the caret placement.
772
+ let marginDrag = false;
773
+ let marginDown = { x: 0, y: 0 };
774
+ const startMarginDrag = (e: PointerEvent) => {
775
+ marginDrag = false;
776
+ marginDown = { x: e.clientX, y: e.clientY };
777
+ if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
778
+ if (effectiveMode === 'reading' || !dragStartsHere(root, e.target)) return;
779
+ const anchor = deadSpaceCaret.anchorAtPoint(root, e.clientX, e.clientY);
780
+ if (!anchor) return;
781
+ marginDrag = true;
782
+ resetForPointerDown(selectionState, stickyColumn, edgeAffinity, false);
783
+ // A block that runs its own drag from a nearby press (a table's cell rectangle) takes
784
+ // it; the generic drag is for blocks that have none.
785
+ if (!('offset' in anchor)) {
786
+ const component = getBlockComponent(anchor.path);
787
+ if (component?.startDragAtPoint?.(e.clientX, e.clientY, e)) return;
788
+ }
789
+ installDragListener(
790
+ {
791
+ editorRoot: root,
792
+ scrollContainer: getScrollHost() ?? root,
793
+ selection: selectionState,
794
+ getBlockElByPath,
795
+ lifetimeSignal: lifetimeController.signal,
796
+ paintSameBlock: true
797
+ },
798
+ anchor,
799
+ e
800
+ );
801
+ };
532
802
  return removeAll(
533
803
  onRoot(root, 'click', handleClick),
534
- onRoot(root, 'mousedown', (e: MouseEvent) => deadSpaceCaret.notePress(root, e))
804
+ installDoubleClickWordSelect(root),
805
+ onRoot(root, 'pointerdown', startMarginDrag),
806
+ onRoot(root, 'mousedown', (e: MouseEvent) => {
807
+ deadSpaceCaret.notePress(root, e);
808
+ if (marginDrag) e.preventDefault();
809
+ })
535
810
  );
536
811
  });
537
812
 
@@ -862,8 +1137,7 @@
862
1137
  resolveImageUrl: resolveImageUrlImpl,
863
1138
  resolveLinkUrl: resolveLinkUrlImpl,
864
1139
  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.
1140
+ // Reading mode forces the affordance off through the prop's own funnel.
867
1141
  blockDragHandles: () => blockDragHandles && effectiveMode !== 'reading',
868
1142
  presentationMode: () => effectiveMode,
869
1143
  theme: () => theme,
@@ -874,89 +1148,45 @@
874
1148
  get onPasteImage() {
875
1149
  return onPasteImage;
876
1150
  },
1151
+ // Accessors for the same reason as onPasteImage above.
1152
+ get onRunCode() {
1153
+ return onRunCode;
1154
+ },
1155
+ get codeMenuItems() {
1156
+ return codeMenuItems;
1157
+ },
877
1158
  brokenImageUrls
878
1159
  } satisfies EditorPolicies);
879
1160
 
880
1161
  // ── Root DOM effects ────────────────────────────────────────────────
881
1162
 
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;
1163
+ // The pre half runs while the outgoing mode still owns the DOM, the post half after the
1164
+ // mode's render key has rebuilt every block; the factory carries the caret across the gap.
1165
+ const modeFlip = createModeFlip({
1166
+ get editorEl() {
1167
+ return editorEl;
1168
+ },
1169
+ get mode() {
1170
+ return effectiveMode;
1171
+ },
1172
+ selection: selectionState,
1173
+ getSelection,
1174
+ getBlockElByPath,
1175
+ isHostChrome,
1176
+ edgeAffinity,
1177
+ // Built below; a flip runs post-init, so the getter reads past the TDZ.
1178
+ get heightOracle() {
1179
+ return heightOracle;
1180
+ },
1181
+ events,
1182
+ restoreCaret: (path, offset) => restoreThroughRevealRoad(caretAt(path, offset), 'mount')
1183
+ });
910
1184
  $effect.pre(() => {
911
1185
  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
- });
1186
+ untrack(() => modeFlip.beforeFlip(mode));
927
1187
  });
928
-
929
- // svelte-ignore state_referenced_locally
930
- let lastEffectiveMode = effectiveMode;
931
1188
  $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);
1189
+ modeFlip.afterFlip(effectiveMode);
960
1190
  });
961
1191
 
962
1192
  // A theme flip invalidates no live edit, so it only has to be announced — for
@@ -1151,22 +1381,14 @@
1151
1381
  // ── Resize invalidation ─────────────────────────────────────────────
1152
1382
 
1153
1383
  // 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).
1384
+ // off this counter; a height-only resize spares the measured cache.
1156
1385
  let widthVersion = $state(0);
1157
1386
  $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;
1387
+ if (!editorEl) return;
1388
+ return installWidthWatcher(editorEl, () => {
1165
1389
  heightOracle.dropMeasured();
1166
1390
  widthVersion++;
1167
1391
  });
1168
- observer.observe(el);
1169
- return () => observer.disconnect();
1170
1392
  });
1171
1393
 
1172
1394
  // The slice's own axis, watched on the RESOLVED port: the window's extent comes from the
@@ -1183,117 +1405,51 @@
1183
1405
 
1184
1406
  // ── Type scale ──────────────────────────────────────────────────────
1185
1407
 
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
- }
1408
+ // A font-size move puts the estimates off several-fold, which no other box in the root
1409
+ // reports; the probe's scale rides the width signal, since a rebuild is what it needs.
1198
1410
  $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);
1411
+ if (!typeScaleProbeEl) return;
1412
+ return installTypeScaleProbe(typeScaleProbeEl, {
1413
+ getScale: () => typeScale,
1414
+ onScale: (next) => {
1415
+ typeScale = next;
1416
+ heightOracle.dropMeasured();
1417
+ widthVersion++;
1418
+ }
1205
1419
  });
1206
- observer.observe(el);
1207
- return () => observer.disconnect();
1208
1420
  });
1209
1421
 
1210
1422
  // ── Header slot compensation ────────────────────────────────────────
1211
1423
 
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
1424
  $effect(() => {
1217
1425
  const el = headerEl;
1218
1426
  if (!el || !editorEl) return;
1219
1427
  const port = getScrollport();
1220
1428
  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);
1429
+ return installHeaderSlotCompensation({
1430
+ el,
1431
+ port,
1432
+ ownsScrollCorrection,
1433
+ revealHoldsScroll: () => topWindowing.revealHoldsScroll()
1231
1434
  });
1232
- observer.observe(el);
1233
- return () => observer.disconnect();
1234
1435
  });
1235
1436
 
1236
1437
  // ── Focus attribution ───────────────────────────────────────────────
1237
1438
 
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');
1439
+ const focusAttribution = createFocusAttribution({
1440
+ get mode() {
1441
+ return effectiveMode;
1253
1442
  }
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.
1443
+ });
1262
1444
  $effect(() => {
1263
1445
  void effectiveMode;
1264
- applyFocusedAttr();
1446
+ focusAttribution.applyForMode();
1265
1447
  });
1266
-
1267
1448
  $effect(() => {
1268
1449
  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));
1450
+ return focusAttribution.install(editorEl);
1296
1451
  });
1452
+
1297
1453
  // ── Top-level windowing ─────────────────────────────────────────────
1298
1454
 
1299
1455
  // Assembled here, after the windowing signals it carries exist; the block
@@ -1308,7 +1464,7 @@
1308
1464
  scrollHost: getScrollHost,
1309
1465
  scrollport: getScrollport,
1310
1466
  blockElLookup: getBlockElByPath,
1311
- focusedPath: () => focusedPath,
1467
+ focusedPath: focusAttribution.getFocusedPath,
1312
1468
  heightOracle,
1313
1469
  correctsScroll: ownsScrollCorrection,
1314
1470
  widthVersion: () => widthVersion,
@@ -1416,6 +1572,11 @@
1416
1572
  * focused surface. A gap caret declines: its proxy is not a block, and a NESTED gap's proxy
1417
1573
  * sits inside its container's host, which must not receive what was aimed at the gap.
1418
1574
  */
1575
+ function blurEditingSurface(root: HTMLElement): void {
1576
+ const active = document.activeElement;
1577
+ if (active instanceof HTMLElement && root.contains(active) && active !== root) active.blur();
1578
+ }
1579
+
1419
1580
  function focusedSurfacePath(): number[] | null {
1420
1581
  if (selectionState.gapCaret) return null;
1421
1582
  const active = document.activeElement;
@@ -1460,10 +1621,10 @@
1460
1621
 
1461
1622
  // The door only resolves the focused surface; every rule (the reading gate, the cross-block
1462
1623
  // range decline, the arms themselves) lives below the seam. See `editor-props.ts`.
1463
- export function runCommand(commandId: string): boolean {
1624
+ export function runCommand(commandId: string, arg?: unknown): boolean {
1464
1625
  return runCommandById(
1465
1626
  commandId as AnyCommandId,
1466
- undefined,
1627
+ arg,
1467
1628
  focusedCommandTarget(),
1468
1629
  commandDispatchContext,
1469
1630
  commandErrorSink
@@ -1632,6 +1793,7 @@
1632
1793
  tabindex="-1"
1633
1794
  role="group"
1634
1795
  aria-label={EDITOR_LABEL}
1796
+ oncontextmenu={onRootContextMenu}
1635
1797
  >
1636
1798
  {#if searchBar}
1637
1799
  <!-- Zero-height sticky anchor, so the bar doesn't scroll away with content. Portaled
@@ -1665,6 +1827,24 @@
1665
1827
  window={topWindowing.window}
1666
1828
  reorderable={true}
1667
1829
  />
1830
+ <!-- A sibling of the list like the header: the windowing scope wants the list bare. -->
1831
+ <TailInsert
1832
+ {blockEdit}
1833
+ childCount={doc.children.length}
1834
+ readOnly={effectiveMode === 'reading'}
1835
+ onPlus={(button) => void onTailPlus(button)}
1836
+ />
1837
+ {#if blockMenu}
1838
+ <BlockMenu
1839
+ x={blockMenu.x}
1840
+ y={blockMenu.y}
1841
+ anchor={blockMenu.anchor}
1842
+ items={blockMenu.items}
1843
+ label={blockMenu.label}
1844
+ onPick={blockMenu.pick}
1845
+ onClose={() => (blockMenu = null)}
1846
+ />
1847
+ {/if}
1668
1848
  <ImageOverlayHost
1669
1849
  {widgetSelection}
1670
1850
  {controller}
@@ -1709,7 +1889,9 @@
1709
1889
  .editor {
1710
1890
  width: 100%;
1711
1891
  flex: 1;
1712
- padding: 1rem;
1892
+ /* Wider on the left: the drag grip lives in that gutter, 1.25rem out from the content,
1893
+ and the rest of this padding is what keeps it off the scroll container's border. */
1894
+ padding: 1rem 1rem 1rem 1.5rem;
1713
1895
  font-family: var(--font-editor, ui-monospace, monospace);
1714
1896
  /* The type-scale root: every construct sizes in `em` off this, so one
1715
1897
  declaration scales the whole surface. */
@@ -1724,9 +1906,10 @@
1724
1906
  double-corrects. Do NOT restore `overflow-anchor` (VR-2). */
1725
1907
  overflow-anchor: none;
1726
1908
  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;
1909
+ scrollbar-color: var(--color-border, #3e3e3b) transparent;
1910
+ /* No box of its own. A document is the page's content, not a widget sitting on it, and
1911
+ an outline around the whole editor reads as a form field the moment the host gives it
1912
+ a column. A host that wants the frame draws it on its own container. */
1730
1913
  /* Containing block for the image overlay portal. */
1731
1914
  position: relative;
1732
1915
  }