@voithos-labs/aragonite 0.10.3 → 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 (101) hide show
  1. package/dist/a11y-strings.d.ts +1 -0
  2. package/dist/a11y-strings.js +1 -0
  3. package/dist/components/BlockDragHandle.svelte +2 -0
  4. package/dist/components/BlockHost.svelte +2 -2
  5. package/dist/components/Editor.svelte +56 -3
  6. package/dist/components/SelectionOverlay.svelte +17 -23
  7. package/dist/components/SelectionOverlay.svelte.d.ts +1 -1
  8. package/dist/components/blocks/code/CodeBlockRail.svelte +42 -24
  9. package/dist/components/blocks/code/code-bootstrap.js +12 -0
  10. package/dist/components/blocks/code/code-languages.d.ts +5 -5
  11. package/dist/components/blocks/code/code-languages.js +20 -12
  12. package/dist/components/blocks/editable-leaf.d.ts +3 -3
  13. package/dist/components/blocks/editable-leaf.js +6 -2
  14. package/dist/components/blocks/list/ListBlock.svelte +1 -0
  15. package/dist/components/blocks/list/ListItemBlock.svelte +12 -3
  16. package/dist/components/blocks/list/ListItemBlock.svelte.d.ts +2 -0
  17. package/dist/components/blocks/text/click-snap-guard.d.ts +3 -0
  18. package/dist/components/blocks/text/click-snap-guard.js +8 -0
  19. package/dist/components/blocks/text/edge-policy-dispatch.js +33 -18
  20. package/dist/components/blocks/text/live-selection-edit.d.ts +5 -5
  21. package/dist/components/blocks/text/live-selection-edit.js +10 -7
  22. package/dist/components/blocks/text/text-clipboard.js +2 -2
  23. package/dist/components/blocks/text/widget-interaction.js +45 -23
  24. package/dist/components/drag-handle.d.ts +6 -0
  25. package/dist/components/drag-handle.js +8 -0
  26. package/dist/components/editor-root-geometry.d.ts +1 -1
  27. package/dist/components/editor-root-geometry.js +1 -1
  28. package/dist/components/editor-root-listeners.d.ts +0 -7
  29. package/dist/components/editor-root-listeners.js +0 -22
  30. package/dist/components/image/ImageResizeHandles.svelte +18 -10
  31. package/dist/components/menu/SelectionToolbar.svelte +385 -0
  32. package/dist/components/menu/SelectionToolbar.svelte.d.ts +16 -0
  33. package/dist/core/inline/inline-widgets.d.ts +12 -4
  34. package/dist/core/inline/inline-widgets.js +5 -0
  35. package/dist/core/inline/transparency.js +3 -3
  36. package/dist/cursor/height-oracle.d.ts +2 -3
  37. package/dist/cursor/height-oracle.js +0 -1
  38. package/dist/cursor/scroll-hold.d.ts +10 -0
  39. package/dist/cursor/scroll-hold.js +22 -0
  40. package/dist/cursor/scrollport.d.ts +9 -0
  41. package/dist/cursor/scrollport.js +30 -1
  42. package/dist/cursor/visual-lines.d.ts +5 -4
  43. package/dist/cursor/visual-lines.js +56 -11
  44. package/dist/cursor/widget-edge-snap.d.ts +28 -0
  45. package/dist/cursor/widget-edge-snap.js +38 -0
  46. package/dist/cursor/widget-offset.d.ts +3 -0
  47. package/dist/cursor/widget-offset.js +10 -0
  48. package/dist/decorations/reserved-attrs.js +1 -0
  49. package/dist/editor-actions/focus/focus-dispatch.js +7 -4
  50. package/dist/editor-actions/focus/focus-landing.d.ts +10 -1
  51. package/dist/editor-actions/focus/focus-landing.js +20 -6
  52. package/dist/editor-actions/plugin/container.d.ts +7 -0
  53. package/dist/editor-actions/plugin/container.js +3 -1
  54. package/dist/editor-props.d.ts +3 -0
  55. package/dist/plugin.d.ts +2 -1
  56. package/dist/plugin.js +6 -1
  57. package/dist/plugins/latex/latex-kind.js +48 -31
  58. package/dist/plugins/latex/math-source.d.ts +3 -3
  59. package/dist/plugins/latex/math-source.js +32 -26
  60. package/dist/plugins/mermaid/MermaidBlock.svelte +19 -9
  61. package/dist/plugins/parrot/ParrotBlock.svelte +3 -1
  62. package/dist/reactivity/list-windowing.svelte.js +26 -6
  63. package/dist/schema/commands.d.ts +9 -5
  64. package/dist/schema/commands.js +12 -7
  65. package/dist/schema/operations.d.ts +1 -1
  66. package/dist/schema/reserved-chords.js +12 -0
  67. package/dist/selection/cross-block/keydown.js +2 -28
  68. package/dist/selection/cross-block/paste.js +7 -13
  69. package/dist/selection/cross-block/type-replace.js +5 -15
  70. package/dist/selection/dead-space-caret.d.ts +3 -0
  71. package/dist/selection/dead-space-caret.js +12 -2
  72. package/dist/selection/drag-pointer.d.ts +19 -0
  73. package/dist/selection/drag-pointer.js +42 -0
  74. package/dist/selection/keyboard-extend.d.ts +3 -2
  75. package/dist/selection/keyboard-extend.js +3 -2
  76. package/dist/selection/multi-click.d.ts +42 -0
  77. package/dist/selection/multi-click.js +203 -0
  78. package/dist/selection/native-bridge.d.ts +3 -0
  79. package/dist/selection/native-bridge.js +31 -2
  80. package/dist/selection/path-lookup.d.ts +10 -2
  81. package/dist/selection/path-lookup.js +10 -3
  82. package/dist/selection/pointer-gesture.d.ts +9 -0
  83. package/dist/selection/pointer-gesture.js +11 -0
  84. package/dist/selection/primitives.d.ts +7 -0
  85. package/dist/selection/primitives.js +19 -1
  86. package/dist/selection/range-delete.d.ts +4 -0
  87. package/dist/selection/range-delete.js +1 -1
  88. package/dist/selection/selection-drop.d.ts +34 -0
  89. package/dist/selection/selection-drop.js +253 -0
  90. package/dist/styles/editor-theme.css +18 -3
  91. package/dist/styles/editor.css +64 -13
  92. package/dist/tree-operations/paste/replace-block-at-parent.d.ts +3 -1
  93. package/dist/tree-operations/paste/replace-block-at-parent.js +4 -1
  94. package/dist/tree-operations/paste/replacement-parse.d.ts +17 -0
  95. package/dist/tree-operations/paste/replacement-parse.js +22 -0
  96. package/docs/guide/consumer-guide.md +10 -8
  97. package/docs/guide/plugin-api.md +54 -38
  98. package/docs/guide/plugin-guide.md +31 -22
  99. package/package.json +7 -7
  100. package/dist/selection/double-click-trim.d.ts +0 -17
  101. package/dist/selection/double-click-trim.js +0 -57
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Dragging a selection and dropping it. The browser's own version is two native edits committed
3
+ * apart — `deleteByDrag` on the source, `insertFromDrop` on the target — so undo takes two presses
4
+ * over a document that lost bytes in between, and inside one block the source commit rebuilds the
5
+ * surface under the drop. Owned here instead: one snapshot over two raw writes, which splice
6
+ * nothing, so no path moves under the second.
7
+ */
8
+ import { emitClipboardError } from '../editor-events';
9
+ import { ambientLengthOf } from '../ambient/ambient-dom';
10
+ import { toClampedRawOffset } from '../cursor/coordinate-spaces';
11
+ import { domTextOffsetAtNode } from '../cursor/widget-offset';
12
+ import { trailingLineEnding, trimTrailingLineEnding } from '../core/lines';
13
+ import { replaceRangeRaw } from '../components/blocks/text/live-selection-edit';
14
+ import { blockNodeAt, emptyParagraph, writeOwnRaw } from '../tree-operations/node-primitives';
15
+ import { cloneNode } from '../tree-operations/clone';
16
+ import { cutRangeFromDisplay } from '../tree-operations/node-ops';
17
+ import { rebuildAncestryRaw } from '../schema/container-raw';
18
+ import { applyPasteTransforms } from '../tree-operations/paste/paste-transforms';
19
+ import { replaceBlockAtParent } from '../tree-operations/paste/replace-block-at-parent';
20
+ import { parseReplacement } from '../tree-operations/paste/replacement-parse';
21
+ import { blockNearPoint } from './nearest-block';
22
+ import { findSurfaceForElement } from './path-lookup';
23
+ import { containerAmbientPrefix } from './range-delete';
24
+ /** A drag that IS this gesture but whose shape the seam does not move — a range leaving its
25
+ * surface, an empty one. The drop cancels it rather than handing it back. */
26
+ const DECLINED = 'declined';
27
+ // ── Public entry ───────────────────────────────────────────────────────────
28
+ export function installSelectionDrop(deps) {
29
+ let source = null;
30
+ const onDragStart = (e) => {
31
+ source = readSelectionSource(deps.editorRoot, e.target);
32
+ };
33
+ const onDragEnd = () => {
34
+ source = null;
35
+ };
36
+ // The editor is the drop target for every drag this seam recognized, declined ones included:
37
+ // the drop below must RUN to cancel the browser's pair of native edits.
38
+ const onDragOver = (e) => {
39
+ if (source)
40
+ e.preventDefault();
41
+ };
42
+ const onDrop = (e) => {
43
+ const from = source;
44
+ source = null;
45
+ if (!from)
46
+ return;
47
+ // Before every decline below, not after: the browser's two edits land apart and its undo
48
+ // is not ours, so a shape this seam declines mutates nothing rather than half of it.
49
+ e.preventDefault();
50
+ if (from === DECLINED || deps.isReadOnly())
51
+ return;
52
+ const target = dropTarget(deps, e.clientX, e.clientY);
53
+ if (!target)
54
+ return;
55
+ const text = movedText(deps, from);
56
+ if (text === null)
57
+ return;
58
+ void runDrop(deps, from, target, text, e.ctrlKey || e.altKey).catch((error) => {
59
+ // A throw between the two writes leaves a snapshot pushed and half the move applied;
60
+ // the host hears it on the channel the paste route reports through.
61
+ emitClipboardError(deps.events, { error, path: from.path });
62
+ });
63
+ };
64
+ deps.editorRoot.addEventListener('dragstart', onDragStart);
65
+ deps.editorRoot.addEventListener('dragend', onDragEnd);
66
+ deps.editorRoot.addEventListener('dragover', onDragOver);
67
+ deps.editorRoot.addEventListener('drop', onDrop);
68
+ return () => {
69
+ deps.editorRoot.removeEventListener('dragstart', onDragStart);
70
+ deps.editorRoot.removeEventListener('dragend', onDragEnd);
71
+ deps.editorRoot.removeEventListener('dragover', onDragOver);
72
+ deps.editorRoot.removeEventListener('drop', onDrop);
73
+ };
74
+ }
75
+ /** Where a splice of `delta` blocks at `at` inside `parent` leaves `target`: what the drop's
76
+ * second write addresses once the first one's reparse changed its slot's block count. */
77
+ export function shiftPathAfterSplice(target, parent, at, delta) {
78
+ if (delta === 0 || target.length <= parent.length)
79
+ return target;
80
+ for (let i = 0; i < parent.length; i++)
81
+ if (target[i] !== parent[i])
82
+ return target;
83
+ if (target[parent.length] <= at)
84
+ return target;
85
+ const shifted = target.slice();
86
+ shifted[parent.length] += delta;
87
+ return shifted;
88
+ }
89
+ /** Where a drop at `offset` lands once `[start, end)` left the same block, `shrunkBy` bytes
90
+ * shorter than the range was wide once the join seam cleaned up. Null inside the range. */
91
+ export function dropOffsetAfterCut(offset, start, end, shrunkBy) {
92
+ if (offset > start && offset < end)
93
+ return null;
94
+ return offset <= start ? offset : Math.max(0, offset - shrunkBy);
95
+ }
96
+ // ── Reading the gesture ────────────────────────────────────────────────────
97
+ /**
98
+ * The native range the drag carries, in its surface's raw offsets. `null` is "not this gesture" and
99
+ * leaves the drag to the browser; {@link DECLINED} is this gesture in a shape the seam does not
100
+ * move, which the drop cancels. A cross-block selection reaches neither: it paints through the
101
+ * overlay and leaves no native range for the browser to drag.
102
+ */
103
+ function readSelectionSource(editorRoot, dragged) {
104
+ const sel = window.getSelection();
105
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0)
106
+ return null;
107
+ const range = sel.getRangeAt(0);
108
+ // A draggable of its own (a rendered link, an image) is not this gesture, even with a range
109
+ // painted elsewhere: a selection drag grips a node the range covers.
110
+ if (!(dragged instanceof Node) || !range.intersectsNode(dragged))
111
+ return null;
112
+ const surface = surfaceOf(range.startContainer);
113
+ if (!surface || !editorRoot.contains(surface))
114
+ return null;
115
+ // Past this point the drag IS the editor's selection, so every remaining shape declines.
116
+ if (!surface.contains(range.endContainer))
117
+ return DECLINED;
118
+ const found = findSurfaceForElement(surface);
119
+ if (!found)
120
+ return DECLINED;
121
+ const ambient = ambientLengthOf(surface);
122
+ const start = toClampedRawOffset(domTextOffsetAtNode(surface, range.startContainer, range.startOffset), ambient);
123
+ const end = toClampedRawOffset(domTextOffsetAtNode(surface, range.endContainer, range.endOffset), ambient);
124
+ return start < end ? { ...found, start, end } : DECLINED;
125
+ }
126
+ /** The block and offset a drop point addresses, as a single-click caret would land. Null where
127
+ * the point names no character position (a whole-block unit, a table cell). */
128
+ function dropTarget(deps, clientX, clientY) {
129
+ const near = blockNearPoint(deps.editorRoot, clientX, clientY);
130
+ const point = near?.endpointHere();
131
+ if (!point || !('offset' in point) || point.cellCoordinate)
132
+ return null;
133
+ return blockNodeAt(deps.getDoc(), point.path) ? { path: point.path, offset: point.offset } : null;
134
+ }
135
+ /** The source surface's own bytes for the range, past the paste transforms. Null for a payload
136
+ * this seam does not own: a line break needs the structural paste route. */
137
+ function movedText(deps, from) {
138
+ const node = blockNodeAt(deps.getDoc(), from.path);
139
+ if (!node)
140
+ return null;
141
+ const raw = trimTrailingLineEnding(node.raw).slice(from.start, from.end);
142
+ const text = applyPasteTransforms(raw, deps.activePlugins);
143
+ return text && !/[\r\n]/.test(text) ? text : null;
144
+ }
145
+ // ── The commit ceremony ────────────────────────────────────────────────────
146
+ async function runDrop(deps, from, to, text, copy) {
147
+ if (copy) {
148
+ await writeBlockRaw(deps, to.path, insert(to.offset, text), to.offset + text.length, true);
149
+ return;
150
+ }
151
+ const cut = cutFrom(deps, from);
152
+ if (!cut)
153
+ return;
154
+ if (pathsEqual(cut.path, to.path)) {
155
+ const offset = dropOffsetAfterCut(to.offset, from.start, from.end, cut.shrunkBy);
156
+ if (offset === null)
157
+ return;
158
+ const merged = spliceAt(cut.raw, offset, text);
159
+ await writeBlockRaw(deps, cut.path, () => merged, offset + text.length, true);
160
+ return;
161
+ }
162
+ deps.controller.pushUndoSnapshotPath(from.path, from.start);
163
+ // A table's caret door reads a cell landing, never a character offset (G1.29), so the
164
+ // transient caret the second write moves off is its first cell.
165
+ const sourceCaret = from.inCell ? 0 : from.start;
166
+ const spliced = await writeBlockRaw(deps, cut.path, () => cut.raw, sourceCaret, false);
167
+ const at = cut.path[cut.path.length - 1];
168
+ const target = shiftPathAfterSplice(to.path, cut.path.slice(0, -1), at, spliced);
169
+ await writeBlockRaw(deps, target, insert(to.offset, text), to.offset + text.length, false);
170
+ }
171
+ function insert(offset, text) {
172
+ return (raw) => spliceAt(raw, offset, text);
173
+ }
174
+ /** The source surface's display bytes with the dragged range gone, through the delete seam so a
175
+ * live-mode join cleans up after itself. */
176
+ function cutFrom(deps, from) {
177
+ const node = blockNodeAt(deps.getDoc(), from.path);
178
+ if (!node)
179
+ return null;
180
+ if (from.inCell)
181
+ return cutFromCell(deps, from, node);
182
+ const before = trimTrailingLineEnding(node.raw);
183
+ const edit = replaceRangeRaw(node, { start: from.start, end: from.end }, '', deps.getPresentationMode?.(), deps.linkRef, containerAmbientPrefix(deps.getDoc(), from.path));
184
+ const raw = trimTrailingLineEnding(edit.raw);
185
+ return { path: from.path, raw, shrunkBy: before.length - raw.length };
186
+ }
187
+ /** A cell's bytes are joined into its row, so the TABLE is the block the cut rewrites: the cell's
188
+ * own range-delete on a copy, then the kind's escape and the ancestry rebuild around it. */
189
+ function cutFromCell(deps, from, cell) {
190
+ const tablePath = from.path.slice(0, -2);
191
+ // The cell path is resolved from a DOM selector contract, so the kind is read, not assumed.
192
+ const table = blockNodeAt(deps.getDoc(), tablePath);
193
+ if (!table || table.kind !== 'table')
194
+ return null;
195
+ const cut = cutRangeFromDisplay(cell, cell.raw, { start: from.start, end: from.end }, deps.getPresentationMode?.(), deps.linkRef);
196
+ const rebuilt = cloneNode(table);
197
+ const inner = from.path.slice(-2);
198
+ const [rowIdx, colIdx] = inner;
199
+ const written = rebuilt.children?.[rowIdx]?.children?.[colIdx];
200
+ if (!written)
201
+ return null;
202
+ writeOwnRaw(written, cut.display, deps.grammar);
203
+ rebuildAncestryRaw(rebuilt, inner);
204
+ const raw = trimTrailingLineEnding(rebuilt.raw);
205
+ return { path: tablePath, raw, shrunkBy: trimTrailingLineEnding(table.raw).length - raw.length };
206
+ }
207
+ /**
208
+ * Replace the block at `path` with the reparse of the bytes `rewrite` returns, at its parent
209
+ * scope. Answers how many blocks the slot grew or shrank by, which is what keeps a second
210
+ * write's path honest. `own` pushes this write's own undo entry.
211
+ */
212
+ async function writeBlockRaw(deps, path, rewrite, caret, own) {
213
+ const doc = deps.getDoc();
214
+ const node = blockNodeAt(doc, path);
215
+ if (!node)
216
+ return 0;
217
+ const written = rewrite(trimTrailingLineEnding(node.raw));
218
+ // A block emptied by the cut keeps its slot as a blank paragraph: no splice, so the second
219
+ // write's path is still the one this gesture resolved.
220
+ const parsed = parseReplacement(node, written, deps.grammar, () => [
221
+ emptyParagraph(node.leadingTrivia ?? '', trailingLineEnding(node.raw))
222
+ ]);
223
+ if (!parsed)
224
+ return 0;
225
+ const landed = await replaceBlockAtParent({
226
+ doc,
227
+ blockPath: path,
228
+ replacement: parsed.replacement,
229
+ controller: deps.coordinator,
230
+ undoEntry: own ? 'own' : 'join',
231
+ focusReplacementIndex: parsed.replacement.length - 1,
232
+ focusOffset: caret,
233
+ source: 'selection-drop',
234
+ ...(deps.grammar ? { grammar: deps.grammar } : {})
235
+ });
236
+ // The LANDED count, not the parse's: a body rule inside the scope can rewrite the list. Zero
237
+ // is the unmounted-scope decline, which wrote nothing and so moved nothing.
238
+ return Math.max(0, landed - 1);
239
+ }
240
+ // ── Small helpers ──────────────────────────────────────────────────────────
241
+ function spliceAt(raw, offset, text) {
242
+ const at = Math.max(0, Math.min(offset, raw.length));
243
+ return raw.slice(0, at) + text + raw.slice(at);
244
+ }
245
+ function pathsEqual(a, b) {
246
+ return a.length === b.length && a.every((v, i) => v === b[i]);
247
+ }
248
+ function surfaceOf(node) {
249
+ let el = node instanceof HTMLElement ? node : node.parentElement;
250
+ while (el && el.contentEditable !== 'true')
251
+ el = el.parentElement;
252
+ return el;
253
+ }
@@ -69,6 +69,10 @@
69
69
  --color-bg-secondary, which is measured against the base bg. */
70
70
  --color-ui-faint: rgba(255, 255, 255, 0.07);
71
71
 
72
+ /* One radius for a picture, the crop frame that clips it and the selection ring on the
73
+ overlay above it: half the surface radius, enough to soften a photo without a card look. */
74
+ --md-image-radius: 4px;
75
+
72
76
  /* Markdown syntax override points, deliberately neutral so the default rendering carries no
73
77
  extra palette. Mode-independent: the chrome tokens they chain to carry the light/dark
74
78
  split. */
@@ -112,8 +116,8 @@
112
116
  /* Presentational tokens (mode-independent). */
113
117
  /* Washes over the host-declarable selection base, alpha apart, so a host that names one
114
118
  base moves the selection, the search matches and the reorder chrome together. */
115
- --selection-overlay-bg: color-mix(in srgb, var(--color-selection, #6496ff) 30%, transparent);
116
- --search-match-bg: color-mix(in srgb, var(--color-selection, #6496ff) 22%, transparent);
119
+ --selection-overlay-bg: rgba(100, 150, 255, 0.3);
120
+ --search-match-bg: rgba(100, 150, 255, 0.22);
117
121
  /* Painted on top of the match text, so it must keep an alpha channel or it hides it. */
118
122
  --search-match-active-bg: rgba(216, 166, 87, 0.55);
119
123
  --md-ref-label-color: rgba(128, 128, 128, 0.45);
@@ -125,7 +129,7 @@
125
129
  --md-reorder-indicator: var(--color-selection, #6496ff);
126
130
  /* Wash on the container being reordered within during a nested drag. Tinted with the reorder
127
131
  accent, not neutral grey, so the shift stays perceptible for low-contrast vision. */
128
- --reorder-scope-bg: color-mix(in srgb, var(--color-selection, #6496ff) 14%, transparent);
132
+ --reorder-scope-bg: rgba(100, 150, 255, 0.14);
129
133
  --syntax-marker-dim: 0.65;
130
134
 
131
135
  /* Menu/popout elevation, per mode: a shadow tuned for a near-black surface is invisible
@@ -143,6 +147,17 @@
143
147
  --vr-spacer-bg: rgba(255, 255, 255, 0.025);
144
148
  }
145
149
 
150
+ /* The washes follow a host's `--color-selection` where the engine can mix; the static values
151
+ above are the same tints of the default base, for engines without `color-mix` (WebKit before
152
+ Safari 16.2). */
153
+ @supports (color: color-mix(in srgb, red, blue)) {
154
+ :where(.editor, .aragonite-editor-theme) {
155
+ --selection-overlay-bg: color-mix(in srgb, var(--color-selection, #6496ff) 30%, transparent);
156
+ --search-match-bg: color-mix(in srgb, var(--color-selection, #6496ff) 22%, transparent);
157
+ --reorder-scope-bg: color-mix(in srgb, var(--color-selection, #6496ff) 14%, transparent);
158
+ }
159
+ }
160
+
146
161
  :where(.editor, .aragonite-editor-theme)[data-editor-theme='light'] {
147
162
  --color-bg-secondary: rgba(0, 0, 0, 0.035);
148
163
  --color-bg-elevated: #f5f6f8;
@@ -168,9 +168,7 @@
168
168
  max-width: 100%;
169
169
  height: auto;
170
170
  display: block;
171
- /* Enough to take the hard corner off a photo sitting in prose, not enough to read as a
172
- card: half the surface radius the block chrome uses. */
173
- border-radius: 4px;
171
+ border-radius: var(--md-image-radius);
174
172
  }
175
173
 
176
174
  /* A cropped image: the widget is the frame (width and aspect set inline from the `|WxH` hint,
@@ -178,15 +176,16 @@
178
176
  :where(.editor) .md-image-widget.md-image-cropped {
179
177
  overflow: hidden;
180
178
  max-width: 100%;
181
- /* The frame clips the picture, so the rounding belongs to it; the picture inside is square
182
- again or its own corners would cut inside the frame's. */
183
- border-radius: 4px;
184
- }
185
-
186
- :where(.editor) .md-image-widget.md-image-cropped img {
187
- border-radius: 0;
179
+ /* The frame clips the picture, so the rounding belongs to it. `clip-path` alongside
180
+ `overflow`, because a rounded overflow clip is the one Chromium drops when the picture
181
+ inside gets its own compositing layer, and a crop is exactly when that happens. */
182
+ border-radius: var(--md-image-radius);
183
+ clip-path: inset(0 round var(--md-image-radius));
188
184
  }
189
185
 
186
+ /* The picture keeps its own radius rather than going square inside the frame: at zoom 1 it sits
187
+ exactly on the frame, so the two corners coincide, and above 1 its corners are outside the
188
+ frame entirely. Either way the crop cannot come out with harder corners than the plain image. */
190
189
  :where(.editor) .md-image-widget.md-image-cropped img {
191
190
  position: absolute;
192
191
  max-width: none;
@@ -321,6 +320,20 @@
321
320
  z-index: 10;
322
321
  }
323
322
 
323
+ /* A selected image says so with a hairline ring. It rides the overlay, not the widget: a cropped
324
+ widget clips its own pseudo-elements, and a border on the picture would move the layout. Held
325
+ just off the edge so it reads as a ring around the picture rather than part of it, over a
326
+ translucent dark line that keeps it legible against a pale photo. */
327
+ :where(.editor) .md-image-overlay::before {
328
+ content: '';
329
+ position: absolute;
330
+ inset: -2px;
331
+ border: 1px solid var(--color-accent, #567b67);
332
+ border-radius: calc(var(--md-image-radius, 4px) + 2px);
333
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18);
334
+ pointer-events: none;
335
+ }
336
+
324
337
  :where(.editor) .md-image-overlay > * {
325
338
  pointer-events: auto;
326
339
  }
@@ -652,12 +665,16 @@
652
665
  .task-checkbox::before {
653
666
  content: '';
654
667
  visibility: visible;
668
+ /* Centred by layout and sized to whole pixels, never by a translate: a transformed box
669
+ rasterises at fractional edges on a scaled desktop and reads shorter than it is wide.
670
+ The unrounded pair is the fallback for a browser without `round()`. */
655
671
  position: absolute;
656
- left: 50%;
657
- top: 50%;
672
+ inset: 0;
673
+ margin: auto;
658
674
  width: 1.2em;
659
675
  height: 1.2em;
660
- transform: translate(-50%, -50%);
676
+ width: round(1.2em, 1px);
677
+ height: round(1.2em, 1px);
661
678
  box-sizing: border-box;
662
679
  border: 1.5px solid var(--color-ui-muted, #8f8f89);
663
680
  border-radius: 0.28em;
@@ -699,6 +716,40 @@
699
716
  transform: translate(-50%, -50%) rotate(45deg);
700
717
  }
701
718
 
719
+ /* The span is the SLOT, a full line-height tall; the box is the pseudo-element inside it. The
720
+ hover tint the source modes put on the span (ListItemBlock) would draw a tall rectangle
721
+ round the square, so in the rendered modes it moves onto the box: an open box fills with
722
+ the tint, a checked one is already a solid fill and keeps it. */
723
+ :where(.editor):is(
724
+ [data-presentation='reading'],
725
+ [data-presentation^='preview-'],
726
+ [data-presentation='live']
727
+ )
728
+ .list-item-block[data-list-marker='task']
729
+ .task-checkbox:hover {
730
+ background-color: transparent;
731
+ }
732
+
733
+ :where(.editor):is(
734
+ [data-presentation='reading'],
735
+ [data-presentation^='preview-'],
736
+ [data-presentation='live']
737
+ )
738
+ .list-item-block[data-list-marker='task']
739
+ .task-checkbox::before {
740
+ transition: background-color 60ms ease-out;
741
+ }
742
+
743
+ :where(.editor):is(
744
+ [data-presentation='reading'],
745
+ [data-presentation^='preview-'],
746
+ [data-presentation='live']
747
+ )
748
+ .list-item-block[data-list-marker='task']:not([data-task-checked='true'])
749
+ .task-checkbox:hover::before {
750
+ background-color: var(--md-marker-hover-bg, rgba(128, 128, 128, 0.15));
751
+ }
752
+
702
753
  /* ── Reading mode ─────────────────────────────────────────────────────────────
703
754
  The marker-hiding families reading shares with the preview rungs and `live` are in the
704
755
  section above; reading adds only its read-only inertness on top — `live` takes none of it. */
@@ -26,4 +26,6 @@ export interface ReplaceBlockAtParentArgs {
26
26
  /** The clipboard's own trailing blank line, where nothing in the splice stands for it. */
27
27
  trailingSeparator?: string;
28
28
  }
29
- export declare function replaceBlockAtParent(args: ReplaceBlockAtParentArgs): Promise<void>;
29
+ /** Answers how many blocks LANDED in the slot — the body rule below can rewrite the list, so a
30
+ * caller whose next write addresses a later sibling asks here rather than counting its own. */
31
+ export declare function replaceBlockAtParent(args: ReplaceBlockAtParentArgs): Promise<number>;
@@ -25,12 +25,14 @@ function landTrailingSeparator(args, children, afterIndex, ending) {
25
25
  return;
26
26
  args.doc.suffix = ending;
27
27
  }
28
+ /** Answers how many blocks LANDED in the slot — the body rule below can rewrite the list, so a
29
+ * caller whose next write addresses a later sibling asks here rather than counting its own. */
28
30
  export async function replaceBlockAtParent(args) {
29
31
  const { doc, blockPath, controller, undoEntry, focusOffset, source } = args;
30
32
  const blockIdx = blockPath[blockPath.length - 1];
31
33
  const scope = resolveParentScope(doc, blockPath, controller);
32
34
  if (!scope)
33
- return;
35
+ return 0;
34
36
  // A replacement is minted before any byte sink sees it, so the owner's bodyWrite escape
35
37
  // lands here — on the clipboard blocks AND the target's split halves alike.
36
38
  const ownerKind = blockPath.length > 1 ? scope.node.kind : undefined;
@@ -72,4 +74,5 @@ export async function replaceBlockAtParent(args) {
72
74
  return controller.landCaret([...scope.path, caret.index], landedPasteOffset(landed, caret, focusOffset));
73
75
  }
74
76
  });
77
+ return replacement.length;
75
78
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The bytes a block is replaced BY, parsed the one way every replace-at-parent caller needs them:
3
+ * terminated in the original's own line ending (G4.20), reparsed in the instance grammar, carrying
4
+ * the original's leading trivia, with editable containers ensured.
5
+ */
6
+ import type { CstNode } from '../../core/nodes';
7
+ import type { GrammarView } from '../../schema/block-openers';
8
+ export interface ParsedReplacement {
9
+ replacement: CstNode[];
10
+ /** The parse's own trailing blank, for a caller that lands one (`paste/dispatch.ts`). */
11
+ suffix: string;
12
+ }
13
+ /**
14
+ * Null where `raw` parses to nothing and the caller named no `fallback` — the block keeps its
15
+ * bytes rather than being replaced by an empty splice.
16
+ */
17
+ export declare function parseReplacement(original: CstNode, raw: string, grammar: GrammarView | undefined, fallback?: () => CstNode[]): ParsedReplacement | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The bytes a block is replaced BY, parsed the one way every replace-at-parent caller needs them:
3
+ * terminated in the original's own line ending (G4.20), reparsed in the instance grammar, carrying
4
+ * the original's leading trivia, with editable containers ensured.
5
+ */
6
+ import { parse } from '../../core/parser';
7
+ import { terminateLine } from '../../core/lines';
8
+ import { ensureEditableContainers, normalizeReplacementTrivia } from '../node-primitives';
9
+ /**
10
+ * Null where `raw` parses to nothing and the caller named no `fallback` — the block keeps its
11
+ * bytes rather than being replaced by an empty splice.
12
+ */
13
+ export function parseReplacement(original, raw, grammar, fallback) {
14
+ const parsed = parse(terminateLine(raw, original.raw), { grammar, scope: 'fragment' });
15
+ const children = parsed.children.length > 0 ? parsed.children : fallback?.();
16
+ if (!children || children.length === 0)
17
+ return null;
18
+ const replacement = normalizeReplacementTrivia(original, children);
19
+ for (const node of replacement)
20
+ ensureEditableContainers(node);
21
+ return { replacement, suffix: parsed.suffix };
22
+ }
@@ -130,10 +130,11 @@ Everything supported is exported from `@voithos-labs/aragonite`. Before 1.0 the
130
130
  | `blockDragHandles` | The block drag handle, revealed on hover and shown outright on touch (default on; reading mode hides it). Only object blocks carry one — code, tables, equations, diagrams, pictures, list items, dividers, cards — never prose. `false` removes them, except on a picture; keyboard reorder (Alt+Arrow) and the cell menu need no opt-in |
131
131
  | `searchBar` | The built-in find/replace bar and its Mod+F / Mod+H shortcuts (default on) |
132
132
  | `searchBarAnchor` | An element to render that same bar into, instead of inside the editor root (see [Where the find bar lives](#where-the-find-bar-lives)) |
133
+ | `selectionToolbar` | The built-in formatting popover over a selection: the marks, the link, a heading picker, inline code and copy (default on; reading mode never shows it; see [Recipe: a selection toolbar](#recipe-a-selection-toolbar)) |
133
134
 
134
135
  **Set once at mount:** `resolveImageUrl`, `resolveLinkUrl`, `imageLoadPolicy`, `onLinkActivate`, `onPasteImage`, `onRunCode`, `codeMenuItems`, `blockDragHandles`, `scrollMode`, and `plugins`. Set them at mount and leave them; a swap later isn't guaranteed to reach blocks that are already built.
135
136
 
136
- **Read live:** `theme`, `searchBar`, `searchBarAnchor`, `presentationMode`, and `keybindings` may change after mount, and `header` re-renders like any other Svelte snippet.
137
+ **Read live:** `theme`, `searchBar`, `searchBarAnchor`, `selectionToolbar`, `presentationMode`, and `keybindings` may change after mount, and `header` re-renders like any other Svelte snippet.
137
138
 
138
139
  ## The instance surface
139
140
 
@@ -283,8 +284,8 @@ editor.runCommand('nope'); // false, unknown id, nothing changed
283
284
 
284
285
  The ids you can pass:
285
286
 
286
- - **`TOOLBAR_COMMANDS`** (exported from the package) has what a selection toolbar needs: `toggleStrong`, `toggleEmphasis`, `toggleStrikethrough`, `toggleCode`, and `editLink`. The rest of the built-in commands stay internal for now.
287
- - **`heading.cycle`, with a level.** The arm behind `Mod+0` to `Mod+6`: `runCommand('heading.cycle', 2)` re-marks the focused prose block as a level-2 heading and `0` makes it a paragraph, which is what a heading picker calls.
287
+ - **`TOOLBAR_COMMANDS`** (exported from the package) has what a selection toolbar needs: `toggleStrong`, `toggleEmphasis`, `toggleStrikethrough`, `toggleCode`, `editLink`, and `setHeading`. The rest of the built-in commands stay internal for now.
288
+ - **`setHeading`, with a level.** The arm behind `Mod+0` to `Mod+6`: `runCommand(TOOLBAR_COMMANDS.setHeading, 2)` re-marks the focused prose block as a level-2 heading and `0` makes it a paragraph, which is what a heading picker calls. A heading level belongs to one block, so over a selection spanning blocks it declines rather than guessing which block you meant.
288
289
  - **A plugin's global command name.** `registerGlobalCommand` registers it (see the [plugin guide](plugin-guide.md)), and it resolves ahead of the focused block, so you can fire a plugin's editor-wide action without a keystroke. A plugin's per-block command stays keyboard-only.
289
290
 
290
291
  `arg` is the argument a keymap binding would bake in (`{ chord: 'Mod+2', command: 'heading.cycle', arg: 2 }`), handed to the command as it is; a command that takes none ignores it.
@@ -292,7 +293,7 @@ The ids you can pass:
292
293
  What the boolean means:
293
294
 
294
295
  - **`true` means the editor took the command, not that the edit has landed.** A toggle inside a construct whose markers a preview mode has revealed (see [Presentation modes](#presentation-modes)) settles that reveal first, so read the outcome on the `edit` channel rather than polling `getSource()`.
295
- - **`false` means nothing changed**: an unknown id, reading mode, a command that needs a focused block when none is, or the link editor over a selection spanning blocks (a link lives inside one block, and a range across blocks gives it none).
296
+ - **`false` means nothing changed**: an unknown id, reading mode, a command that needs a focused block when none is, or the link editor or a heading level over a selection spanning blocks (a link lives inside one block, a heading level is one block's, and a range across blocks gives them none).
296
297
 
297
298
  Two more things before you wire buttons:
298
299
 
@@ -301,12 +302,13 @@ Two more things before you wire buttons:
301
302
 
302
303
  `canRunCommand(commandId: string): boolean`
303
304
 
304
- Tells you whether `runCommand(id)` would reach the command right now, which is what greys a toolbar button out instead of hiding it. It answers `false` exactly where `runCommand` declines before dispatch: an unknown id, reading mode, a block-scoped id with nothing focused, and the link editor while the selection spans blocks. `true` means reachable, not that it'll write (across blocks it may find no block that can hold the mark), so keep reading `runCommand`'s boolean too.
305
+ Tells you whether `runCommand(id)` would reach the command right now, which is what greys a toolbar button out instead of hiding it. It answers `false` exactly where `runCommand` declines before dispatch: an unknown id, reading mode, a block-scoped id with nothing focused, and the link editor or a heading level while the selection spans blocks. `true` means reachable, not that it'll write (across blocks it may find no block that can hold the mark), so keep reading `runCommand`'s boolean too.
305
306
 
306
307
  ```ts
307
308
  // with a selection spanning two paragraphs
308
309
  editor.canRunCommand(TOOLBAR_COMMANDS.toggleStrong); // true
309
310
  editor.canRunCommand(TOOLBAR_COMMANDS.editLink); // false, a link can't span blocks
311
+ editor.canRunCommand(TOOLBAR_COMMANDS.setHeading); // false, a heading level is one block's
310
312
  ```
311
313
 
312
314
  `isCommandActive(commandId: string): boolean`
@@ -1047,7 +1049,7 @@ The bundled toc plugin does exactly that walk over its live document, and clicki
1047
1049
 
1048
1050
  ### Recipe: a selection toolbar
1049
1051
 
1050
- Float a formatting bar above the user's selection. Nine steps, and the anchoring ones have a snippet after the list:
1052
+ The editor ships one: a popover that opens beside a prose selection with the marks, the link, a heading picker (inside one block only), inline code and copy, on by default and off with `selectionToolbar={false}`. It is built on the doors below and nothing else, so this recipe is also how to replace it with your own. Nine steps, and the anchoring ones have a snippet after the list:
1051
1053
 
1052
1054
  1. **Subscribe to `selectionChange`.** A `null` payload or a collapsed selection (anchor equals focus) hides the bar.
1053
1055
  2. **Put the endpoints in document order first.** `normalizeSelection(snapshot)` answers `{ start, end }` (by path, then by offset when the paths match), so a backward drag anchors exactly like a forward one. Anchor to `start`; a hand-rolled comparison gets the container-and-its-child pair wrong, where the shorter path is the earlier one.
@@ -1055,7 +1057,7 @@ Float a formatting bar above the user's selection. Nine steps, and the anchoring
1055
1057
  4. **Single-block selections**: `getSelection()` reports the range's real endpoints, so anchor with `rangeRects(start.path, start.offset, end.offset)`, the same call with a real end offset in place of `SELECTION_END`. (Reading the native `window.getSelection()` range works too, since within one block the editor delegates selection to the browser.) A selection **inside a table** shares the table's path on both endpoints and carries cell indices in `offset`, which the `cellCoordinate` flag need not mark, so exclude it with `getBlockKindAt(start.path) === 'table'`, never by the flag alone.
1056
1058
  5. **Re-anchor on the next `selectionChange`, not on scroll.** Rects are viewport-space snapshots; a `position: fixed` bar drifts under scroll until the selection next changes. Wire a scroll listener only if your UX demands live tracking.
1057
1059
  6. **Fire the buttons through `runCommand`, not synthetic keystrokes.** `runCommand(TOOLBAR_COMMANDS.toggleStrong)` says what the button means; a synthesized `Ctrl+B` says which key the button impersonates, and a user's rebind then silently rewires it.
1058
- 7. **Grey the declining buttons out with `canRunCommand`, on the same `selectionChange`.** Ask it per button and disable the ones that answer `false`, so a selection spanning blocks shows the link button dimmed rather than dead while the format toggles stay live. Still read `runCommand`'s boolean, per [Toolbar commands](#toolbar-commands).
1060
+ 7. **Grey the declining buttons out with `canRunCommand`, on the same `selectionChange`.** Ask it per button and disable the ones that answer `false`, so a selection spanning blocks shows the link button dimmed rather than dead while the format toggles stay live (the editor's own bar goes one further and drops a labelled row the door declines, which is why its heading picker vanishes there). Still read `runCommand`'s boolean, per [Toolbar commands](#toolbar-commands).
1059
1061
  8. **Paint the pressed states with `isCommandActive`, on that same `selectionChange`.** A selection already inside a bold run shows the bold button pressed (`aria-pressed` is the accessible spelling), and pressing it then unwraps: the pressed paint and the press read the same bytes, so they agree by construction. In live mode a selection sitting inside a link shows the link button pressed the same way, off the link the card would edit, and clicking it opens that link's card with the selection left alone; a selection that runs out of the link isn't inside it, so the button unpresses and the click falls back to creating a new link over the range.
1060
1062
  9. **Keep focus in the document**, for the same reason the insert toolbar does: cancel the button's mousedown default, or restore a `getSelection()` snapshot before calling.
1061
1063
 
@@ -1073,7 +1075,7 @@ editor.getEvents().on('selectionChange', (sel) => {
1073
1075
  });
1074
1076
  ```
1075
1077
 
1076
- The repository's `SelectionToolbar` component, mounted by the showcase's live mode and the dev harness alike, is this recipe end to end: both anchoring branches, the table exclusion, the five `TOOLBAR_COMMANDS` buttons greyed by `canRunCommand` and pressed by `isCommandActive`, and the mousedown cancel that keeps the caret in the document.
1078
+ The editor's own bar (`src/lib/components/menu/SelectionToolbar.svelte`) is this recipe end to end: both anchoring branches, the table exclusion, the `TOOLBAR_COMMANDS` buttons greyed by `canRunCommand` and pressed by `isCommandActive`, and the mousedown cancel that keeps the caret in the document.
1077
1079
 
1078
1080
  ### Recipe: an insert toolbar
1079
1081