@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,385 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The editor's own formatting popover over a selection, in Notion's shape: a row of the
4
+ * marks, then labelled rows for what acts on the selection as a whole. Built on the same
5
+ * doors a host's chrome would use (`selectionChange`, the rect API, the command door), so
6
+ * the consumer-guide recipe and this component cannot disagree about what a selection
7
+ * toolbar may read.
8
+ */
9
+ import type { EditorInstance } from '../../editor-props';
10
+ import type { EditorSelection } from '../../selection/primitives';
11
+ import { normalize as normalizeSelection } from '../../selection/primitives';
12
+ import { SELECTION_END } from '../../block-component';
13
+ import { TOOLBAR_COMMANDS } from '../../schema/commands';
14
+ import { SELECTION_TOOLBAR_LABEL } from '../../a11y-strings';
15
+ import { keepFlyoutOnScreen } from './flyout-placement';
16
+ import { runClipboardAction } from './clipboard-actions';
17
+ import MenuIcon, { type MenuIconName } from './MenuIcon.svelte';
18
+
19
+ type ToolbarEditor = Pick<
20
+ EditorInstance,
21
+ | 'getSelection'
22
+ | 'getRects'
23
+ | 'getBlockKindAt'
24
+ | 'runCommand'
25
+ | 'canRunCommand'
26
+ | 'isCommandActive'
27
+ | 'getEvents'
28
+ >;
29
+
30
+ // The bar: the three marks and the link. Beneath it, as rows: the heading picker, inline code
31
+ // and copy. The bar sets the card's width.
32
+ const MARKS: readonly { icon: MenuIconName; title: string; command: string; size: number }[] = [
33
+ { icon: 'bold', title: 'Bold (Ctrl/Cmd+B)', command: TOOLBAR_COMMANDS.toggleStrong, size: 16 },
34
+ {
35
+ icon: 'italic',
36
+ title: 'Italic (Ctrl/Cmd+I)',
37
+ command: TOOLBAR_COMMANDS.toggleEmphasis,
38
+ size: 16
39
+ },
40
+ {
41
+ icon: 'strikethrough',
42
+ title: 'Strikethrough (Ctrl/Cmd+Shift+X)',
43
+ command: TOOLBAR_COMMANDS.toggleStrikethrough,
44
+ size: 16
45
+ },
46
+ { icon: 'link', title: 'Edit link (Ctrl/Cmd+K)', command: TOOLBAR_COMMANDS.editLink, size: 10 }
47
+ ];
48
+ const ROWS: readonly { icon: MenuIconName; label: string; command: string }[] = [
49
+ { icon: 'code', label: 'Inline code', command: TOOLBAR_COMMANDS.toggleCode }
50
+ ];
51
+ const TURN_INTO_COMMAND = TOOLBAR_COMMANDS.setHeading;
52
+ const TURN_INTO: readonly { label: string; level: number }[] = [
53
+ { label: 'Normal text', level: 0 },
54
+ { label: 'Heading 1', level: 1 },
55
+ { label: 'Heading 2', level: 2 },
56
+ { label: 'Heading 3', level: 3 }
57
+ ];
58
+ const BUTTONS = [...MARKS, ...ROWS, { command: TURN_INTO_COMMAND }];
59
+ const BAR_GAP = 6;
60
+
61
+ let { editor, root }: { editor: ToolbarEditor; root: HTMLElement | undefined } = $props();
62
+
63
+ let turnIntoOpen = $state(false);
64
+ let blockKind = $state<string | null>(null);
65
+ let placement = $state<Placement | null>(null);
66
+ let declined = $state<ReadonlySet<string>>(new Set());
67
+ let active = $state<ReadonlySet<string>>(new Set());
68
+ let current: EditorSelection | null = null;
69
+ let barEl: HTMLDivElement | undefined = $state();
70
+
71
+ // A labelled row the door declines leaves the bar, where a mark only dims: the icons keep the
72
+ // bar's shape, a dead row is a line of noise over a selection it cannot act on.
73
+ const rows = $derived(ROWS.filter((row) => !declined.has(row.command)));
74
+ const offersHeading = $derived(!declined.has(TURN_INTO_COMMAND));
75
+
76
+ interface Placement {
77
+ x: number;
78
+ y: number;
79
+ /** The selection's first rect, for the flip above when there is no room below. */
80
+ flipY: number;
81
+ }
82
+
83
+ // The editor root's top is the floor the flip may not cross: whatever host chrome sits
84
+ // above the editor ends where the root begins.
85
+ function topInset(): number {
86
+ return Math.max(0, root?.getBoundingClientRect().top ?? 0);
87
+ }
88
+
89
+ // The document's own copy, so a cross-block range lands as the Markdown Ctrl+C would write.
90
+ function copySelection(): void {
91
+ void runClipboardAction('copy', null);
92
+ placement = null;
93
+ }
94
+
95
+ // The bar's own size, measured once it is in the DOM, so the placement can keep it on screen:
96
+ // pushed left at the right edge, flipped above the selection when there is no room below.
97
+ let size = $state<{ w: number; h: number } | null>(null);
98
+ $effect(() => {
99
+ if (!barEl || !placement) return;
100
+ const rect = barEl.getBoundingClientRect();
101
+ size = { w: rect.width, h: rect.height };
102
+ });
103
+ const shown = $derived.by(() => {
104
+ if (!placement) return null;
105
+ if (!size) return { x: placement.x, y: placement.y };
106
+ const margin = 8;
107
+ let { x, y } = placement;
108
+ if (x + size.w > window.innerWidth - margin)
109
+ x = Math.max(margin, window.innerWidth - margin - size.w);
110
+ if (y + size.h > window.innerHeight - margin)
111
+ y = Math.max(topInset() + 4, placement.flipY - size.h - BAR_GAP);
112
+ return { x, y };
113
+ });
114
+
115
+ $effect(() => editor.getEvents().on('selectionChange', update));
116
+
117
+ // Only once the drag is over: a bar that appears and re-seats under a moving pointer is in the
118
+ // way of the very selection being made. The press is watched at the document, since the drag
119
+ // can end anywhere; the release places the bar from the selection that stood at that moment.
120
+ let pointerHeld = false;
121
+ $effect(() => {
122
+ // A press on the bar itself is a button, not a drag: arming the release here would re-place
123
+ // the bar (and close its flyout) under the click that follows.
124
+ const down = (e: PointerEvent) => {
125
+ if (e.button === 0 && !barEl?.contains(e.target as Node)) pointerHeld = true;
126
+ };
127
+ // Placed from the range the editor last reported; a release that still moves the range
128
+ // reports again through `selectionChange`, which re-places.
129
+ const up = () => {
130
+ if (!pointerHeld) return;
131
+ pointerHeld = false;
132
+ if (current) update(current);
133
+ };
134
+ document.addEventListener('pointerdown', down, true);
135
+ document.addEventListener('pointerup', up, true);
136
+ document.addEventListener('pointercancel', up, true);
137
+ return () => {
138
+ document.removeEventListener('pointerdown', down, true);
139
+ document.removeEventListener('pointerup', up, true);
140
+ document.removeEventListener('pointercancel', up, true);
141
+ };
142
+ });
143
+
144
+ // The editor's own right-click menu takes the selection's spot; two cards over one range read
145
+ // as a glitch, so the bar hides while it is open and comes back where the selection still is.
146
+ let menuOpen = false;
147
+ $effect(() =>
148
+ editor.getEvents().on('menuChange', (open) => {
149
+ menuOpen = open;
150
+ if (open) placement = null;
151
+ else if (current) update(current);
152
+ })
153
+ );
154
+
155
+ // Sticky with the text: the rects are viewport coordinates, so a scroll or resize moves the
156
+ // selection under the bar and the bar re-measures. Scroll already arrives once per frame.
157
+ $effect(() => {
158
+ const reanchor = () => {
159
+ // Same gates as a selection change: a scroll mid-drag (autoscroll included) must not
160
+ // bring the bar out before the release does.
161
+ if (current) placement = shownOver(current);
162
+ };
163
+ window.addEventListener('scroll', reanchor, { capture: true, passive: true });
164
+ window.addEventListener('resize', reanchor, { passive: true });
165
+ return () => {
166
+ window.removeEventListener('scroll', reanchor, { capture: true });
167
+ window.removeEventListener('resize', reanchor);
168
+ };
169
+ });
170
+
171
+ function update(selection: EditorSelection | null): void {
172
+ current = selection;
173
+ turnIntoOpen = false;
174
+ blockKind = selection ? editor.getBlockKindAt(normalizeSelection(selection).start.path) : null;
175
+ // Asked per selection change, not per render: the answers are snapshots of this selection.
176
+ declined = new Set(
177
+ BUTTONS.filter((b) => !editor.canRunCommand(b.command)).map((b) => b.command)
178
+ );
179
+ active = new Set(
180
+ BUTTONS.filter((b) => editor.isCommandActive(b.command)).map((b) => b.command)
181
+ );
182
+ placement = selection ? shownOver(selection) : null;
183
+ }
184
+
185
+ // The door decides where the bar belongs: over a code fence or an equation's source it declines
186
+ // every mark, and a bar of greyed buttons is worse than none.
187
+ function shownOver(selection: EditorSelection): Placement | null {
188
+ if (menuOpen || pointerHeld) return null;
189
+ if (MARKS.every((mark) => declined.has(mark.command))) return null;
190
+ return place(selection);
191
+ }
192
+
193
+ function place(selection: EditorSelection): Placement | null {
194
+ const { start, end } = normalizeSelection(selection);
195
+ const sameBlock = start.path.join('.') === end.path.join('.');
196
+ if (!sameBlock) {
197
+ // The bar hangs off where the selection ENDS, so the end block's rects come first. A
198
+ // selection that starts at the very end of one block (a drag that left the block
199
+ // upward, or began at the previous paragraph's end) leaves the start block's remaining
200
+ // range empty, so that read is a fallback, and the start block's whole box the last one.
201
+ const rects = editor.getRects();
202
+ const endRects =
203
+ editor.getBlockKindAt(end.path) !== 'table' && end.offset > 0
204
+ ? rects.rangeRects(end.path, 0, end.offset)
205
+ : [];
206
+ const anchored = endRects.length
207
+ ? endRects
208
+ : rects.rangeRects(start.path, start.offset, SELECTION_END);
209
+ const measured = anchored.length ? anchored : rects.rangeRects(start.path, 0, SELECTION_END);
210
+ return measured.length ? belowRight(measured) : null;
211
+ }
212
+ // An intra-table rectangle shares the table's path and carries cell indices on endpoints the
213
+ // flag need not mark, so the kind read is what excludes it rather than the flag.
214
+ if (editor.getBlockKindAt(start.path) === 'table') return null;
215
+ if (start.offset === end.offset) return null;
216
+ const rects = editor.getRects().rangeRects(start.path, start.offset, end.offset);
217
+ return rects.length ? belowRight(rects) : null;
218
+ }
219
+
220
+ /** The bar opens like a menu: below the selection's last line and to the right of where it
221
+ * ends, never over the text. The clamp above pushes it left at the viewport edge and flips it
222
+ * above the first line when there is no room below. */
223
+ function belowRight(rects: DOMRect[]): Placement | null {
224
+ const first = rects[0];
225
+ const last = rects[rects.length - 1];
226
+ // Scrolled out from under the host's chrome or off the bottom: nothing to anchor to.
227
+ if (last.bottom < topInset() || first.top > window.innerHeight) return null;
228
+ return { x: last.right + BAR_GAP, y: last.bottom + BAR_GAP, flipY: first.top };
229
+ }
230
+
231
+ // The id, not a synthesized chord: a host rebind moves the shortcut and leaves the button. A
232
+ // declined run means the bar's premise went stale under it, so the boolean hides the bar.
233
+ function fire(command: string, arg?: unknown): void {
234
+ turnIntoOpen = false;
235
+ if (!editor.runCommand(command, arg)) placement = null;
236
+ }
237
+ </script>
238
+
239
+ {#if shown}
240
+ <div
241
+ bind:this={barEl}
242
+ class="md-menu selection-toolbar"
243
+ data-testid="selection-toolbar"
244
+ style:left="{shown.x}px"
245
+ style:top="{shown.y}px"
246
+ role="toolbar"
247
+ aria-label={SELECTION_TOOLBAR_LABEL}
248
+ tabindex="-1"
249
+ onpointerleave={() => (turnIntoOpen = false)}
250
+ >
251
+ <div class="selection-toolbar-marks">
252
+ {#each MARKS as button (button.command)}
253
+ <button
254
+ type="button"
255
+ class="md-menu-item selection-toolbar-mark"
256
+ data-testid="toolbar-{button.command}"
257
+ title={button.title}
258
+ disabled={declined.has(button.command)}
259
+ aria-pressed={active.has(button.command)}
260
+ onmousedown={(e) => e.preventDefault()}
261
+ onclick={() => fire(button.command)}
262
+ >
263
+ <MenuIcon name={button.icon} size={button.size} />
264
+ </button>
265
+ {/each}
266
+ </div>
267
+ <div class="md-menu-divider" aria-hidden="true"></div>
268
+ {#if offersHeading}
269
+ <div
270
+ class="selection-toolbar-row"
271
+ role="presentation"
272
+ onpointerenter={() => (turnIntoOpen = true)}
273
+ >
274
+ <button
275
+ type="button"
276
+ class="md-menu-item"
277
+ data-testid="toolbar-set-heading"
278
+ aria-haspopup="menu"
279
+ aria-expanded={turnIntoOpen}
280
+ onmousedown={(e) => e.preventDefault()}
281
+ onclick={() => (turnIntoOpen = !turnIntoOpen)}
282
+ >
283
+ <span class="md-menu-icon"><MenuIcon name="heading" size={14} /></span>
284
+ <span class="selection-toolbar-label">Set heading</span>
285
+ <span class="md-menu-icon"><MenuIcon name="chevron-right" size={13} /></span>
286
+ </button>
287
+ {#if turnIntoOpen}
288
+ <div class="md-menu selection-toolbar-flyout" role="menu" {@attach keepFlyoutOnScreen}>
289
+ {#each TURN_INTO as option (option.level)}
290
+ {@const chosen = option.level === 0 ? blockKind === 'paragraph' : false}
291
+ <button
292
+ type="button"
293
+ class="md-menu-item"
294
+ role="menuitemradio"
295
+ data-testid="toolbar-set-heading-{option.level}"
296
+ aria-checked={chosen}
297
+ onmousedown={(e) => e.preventDefault()}
298
+ onclick={() => fire(TURN_INTO_COMMAND, option.level)}
299
+ >
300
+ <span class="selection-toolbar-label">{option.label}</span>
301
+ {#if chosen}<span class="md-menu-icon"><MenuIcon name="check" size={13} /></span
302
+ >{/if}
303
+ </button>
304
+ {/each}
305
+ </div>
306
+ {/if}
307
+ </div>
308
+ {/if}
309
+ {#each rows as row (row.command)}
310
+ <button
311
+ type="button"
312
+ class="md-menu-item"
313
+ data-testid="toolbar-{row.command}"
314
+ aria-pressed={active.has(row.command)}
315
+ onmousedown={(e) => e.preventDefault()}
316
+ onclick={() => fire(row.command)}
317
+ >
318
+ <span class="md-menu-icon"><MenuIcon name={row.icon} size={14} /></span>
319
+ <span class="selection-toolbar-label">{row.label}</span>
320
+ {#if active.has(row.command)}<span class="md-menu-icon"
321
+ ><MenuIcon name="check" size={13} /></span
322
+ >{/if}
323
+ </button>
324
+ {/each}
325
+ <button
326
+ type="button"
327
+ class="md-menu-item"
328
+ data-testid="toolbar-copy"
329
+ onmousedown={(e) => e.preventDefault()}
330
+ onclick={copySelection}
331
+ >
332
+ <span class="md-menu-icon"><MenuIcon name="copy" size={14} /></span>
333
+ <span>Copy</span>
334
+ </button>
335
+ </div>
336
+ {/if}
337
+
338
+ <style>
339
+ /* Surface and rows are the shared `.md-menu` family (editor.css); only the marks row and the
340
+ flyout hang are this popover's own. */
341
+ .selection-toolbar {
342
+ z-index: 100;
343
+ min-width: 0;
344
+ width: calc(4 * 34px + 3 * 2px + 2 * 6px);
345
+ white-space: nowrap;
346
+ }
347
+ .selection-toolbar-marks {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: 2px;
351
+ padding: 2px;
352
+ }
353
+ .selection-toolbar-mark {
354
+ flex: 0 0 34px;
355
+ justify-content: center;
356
+ width: 34px;
357
+ height: 30px;
358
+ padding: 0;
359
+ color: var(--color-ui-muted, #8f8f89);
360
+ }
361
+ .selection-toolbar-mark:hover:not(:disabled),
362
+ .selection-toolbar-mark[aria-pressed='true'] {
363
+ color: var(--color-text-primary, #e8e8e5);
364
+ }
365
+ /* The marks are serif letters; a Lucide outline beside them needs a heavier stroke to weigh
366
+ the same. */
367
+ .selection-toolbar-mark :global(svg) {
368
+ stroke-width: 2.2;
369
+ }
370
+ .selection-toolbar-row {
371
+ position: relative;
372
+ }
373
+ .selection-toolbar-label {
374
+ flex: 1;
375
+ overflow: hidden;
376
+ text-overflow: ellipsis;
377
+ }
378
+ .selection-toolbar-flyout {
379
+ position: absolute;
380
+ left: 100%;
381
+ top: -4px;
382
+ margin-left: 4px;
383
+ min-width: 150px;
384
+ }
385
+ </style>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The editor's own formatting popover over a selection, in Notion's shape: a row of the
3
+ * marks, then labelled rows for what acts on the selection as a whole. Built on the same
4
+ * doors a host's chrome would use (`selectionChange`, the rect API, the command door), so
5
+ * the consumer-guide recipe and this component cannot disagree about what a selection
6
+ * toolbar may read.
7
+ */
8
+ import type { EditorInstance } from '../../editor-props';
9
+ type ToolbarEditor = Pick<EditorInstance, 'getSelection' | 'getRects' | 'getBlockKindAt' | 'runCommand' | 'canRunCommand' | 'isCommandActive' | 'getEvents'>;
10
+ type $$ComponentProps = {
11
+ editor: ToolbarEditor;
12
+ root: HTMLElement | undefined;
13
+ };
14
+ declare const SelectionToolbar: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type SelectionToolbar = ReturnType<typeof SelectionToolbar>;
16
+ export default SelectionToolbar;
@@ -69,15 +69,20 @@ export interface InlineWidgetEditingPolicy {
69
69
  revealSource?: boolean;
70
70
  /**
71
71
  * Where this kind's editable CONTENT sits inside its source span, as offsets relative to
72
- * that span — `$x$` answers `{ start: 1, end: 2 }`. A click that reveals the source seats
73
- * the caret at `end`, so typing continues the construct instead of escaping past its
74
- * closing delimiter. Only the kind knows its own delimiters; absent, the caret keeps the
75
- * leading edge.
72
+ * that span — `$x$` answers `{ start: 1, end: 2 }`. It bounds a caret entering the source,
73
+ * and `end` is where a revealing click lands when the kind maps no point of its own. Only
74
+ * the kind knows its own delimiters; absent, the caret keeps the leading edge.
76
75
  */
77
76
  revealContentSpan?: (source: string) => {
78
77
  start: number;
79
78
  end: number;
80
79
  } | null;
80
+ /**
81
+ * The offset in `source` a press on the RENDERED widget names, so a click seats the caret
82
+ * where it landed rather than at one edge. Only the kind can map its render back to bytes (a
83
+ * KaTeX island paints glyphs, not source); null declines this point and keeps the fallback.
84
+ */
85
+ revealOffsetAtPoint?: (widgetEl: HTMLElement, source: string, clientX: number, clientY: number) => number | null;
81
86
  deleteGranularity?: (typeof DELETE_GRANULARITIES)[number];
82
87
  onEdge?: (typeof ON_EDGE_POLICIES)[number];
83
88
  onSelectedKey?: (e: KeyboardEvent, ctx: InlineWidgetEditingContext) => boolean;
@@ -118,6 +123,9 @@ export declare function augmentInlineWidgetKind(kind: AnyInlineKind, editing: Pa
118
123
  /** Kind-level recognition, independent of per-block render policy (renderImagesAsWidgets). */
119
124
  export declare function isInlineWidget(node: InlineNode, raw: string): boolean;
120
125
  export declare function getInlineWidgetEditing(kind: AnyInlineKind): InlineWidgetEditingPolicy | undefined;
126
+ /** A kind the caret reads as one character: it steps over in one press, carries a column of its
127
+ * own, and a press ON its glyph names an edge rather than selecting the island whole. */
128
+ export declare function isCharacterLikeWidget(kind: AnyInlineKind): boolean;
121
129
  export declare function getInlineWidgetComponent(kind: AnyInlineKind): Component<InlineWidgetComponentProps> | undefined;
122
130
  /**
123
131
  * Every live widget reachable from `nodes`, in document order. Descends so a widget nested in a
@@ -74,6 +74,11 @@ export function isInlineWidget(node, raw) {
74
74
  export function getInlineWidgetEditing(kind) {
75
75
  return registry.get(kind)?.editing;
76
76
  }
77
+ /** A kind the caret reads as one character: it steps over in one press, carries a column of its
78
+ * own, and a press ON its glyph names an edge rather than selecting the island whole. */
79
+ export function isCharacterLikeWidget(kind) {
80
+ return getInlineWidgetEditing(kind)?.onEdge === 'step-over';
81
+ }
77
82
  export function getInlineWidgetComponent(kind) {
78
83
  return registry.get(kind)?.component;
79
84
  }
@@ -5,7 +5,7 @@
5
5
  * an unparsed block degrades to "land on it".
6
6
  */
7
7
  import { getInlineContent } from './inline-cache';
8
- import { isInlineWidget, getInlineWidgetEditing } from './inline-widgets';
8
+ import { isInlineWidget, isCharacterLikeWidget } from './inline-widgets';
9
9
  export function isVerticallyTransparentNode(node) {
10
10
  if (!node)
11
11
  return false;
@@ -26,8 +26,8 @@ export function isVerticallyTransparentNode(node) {
26
26
  return false;
27
27
  for (const inline of inlines) {
28
28
  if (isInlineWidget(inline, node.raw)) {
29
- // A step-over widget is character-like: it carries a column, so it reads as text.
30
- if (getInlineWidgetEditing(inline.kind)?.onEdge === 'step-over')
29
+ // A character-like widget carries a column, so it reads as text.
30
+ if (isCharacterLikeWidget(inline.kind))
31
31
  return false;
32
32
  continue;
33
33
  }
@@ -16,9 +16,8 @@ export interface HeightOracle {
16
16
  estimate(node: NodeView, width: number): number;
17
17
  measured(id: string): number | undefined;
18
18
  recordMeasured(id: string, height: number): void;
19
- /** measured(id) ?? estimate(node, width). */
20
- height(id: string, node: NodeView, width: number): number;
21
- /** Drop every measured height; estimates carry the model until each block re-measures. */
19
+ /** Drop every measured height. A live scope carries its own across a rebuild (VR-15), so
20
+ * estimates seed only a scope built after the drop, and any block with a fresh id. */
22
21
  dropMeasured(): void;
23
22
  }
24
23
  export declare function createHeightOracle(opts: HeightOracleOptions): HeightOracle;
@@ -88,7 +88,6 @@ export function createHeightOracle(opts) {
88
88
  recordMeasured: (id, height) => {
89
89
  measuredById.set(id, height);
90
90
  },
91
- height: (id, node, width) => measuredById.get(id) ?? estimate(node, width),
92
91
  dropMeasured: () => measuredById.clear()
93
92
  };
94
93
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Scroll-position hold across a block's render swap. A swap that shortens a block clamps the
3
+ * scrollport against the transient layout on the way to its settled height, and the clamp is
4
+ * never given back. A reveal in flight outranks the hold and owns the position; host mode does
5
+ * not, since native anchoring cannot undo a max-scroll clamp. Only a measure-then-fit surface
6
+ * needs this, and today that is one: an editable leaf's source is content-sized at mount.
7
+ */
8
+ /** Capture the position of whatever scrolls `el`; await the result AFTER the state flip that
9
+ * changes the block's height. */
10
+ export declare function captureScrollPosition(el: HTMLElement | null | undefined, isRevealInFlight: () => boolean): () => Promise<void>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Scroll-position hold across a block's render swap. A swap that shortens a block clamps the
3
+ * scrollport against the transient layout on the way to its settled height, and the clamp is
4
+ * never given back. A reveal in flight outranks the hold and owns the position; host mode does
5
+ * not, since native anchoring cannot undo a max-scroll clamp. Only a measure-then-fit surface
6
+ * needs this, and today that is one: an editable leaf's source is content-sized at mount.
7
+ */
8
+ import { tick } from 'svelte';
9
+ import { userScrollportFor } from './scroll-ancestors';
10
+ import { createScrollport } from './scrollport';
11
+ /** Capture the position of whatever scrolls `el`; await the result AFTER the state flip that
12
+ * changes the block's height. */
13
+ export function captureScrollPosition(el, isRevealInFlight) {
14
+ const port = el ? createScrollport(userScrollportFor(el)) : null;
15
+ const before = port?.scrollTop() ?? 0;
16
+ return async () => {
17
+ await tick();
18
+ if (!port || isRevealInFlight() || port.scrollTop() === before)
19
+ return;
20
+ port.setScrollTop(before);
21
+ };
22
+ }
@@ -14,7 +14,16 @@ export interface Scrollport {
14
14
  contentWidth(): number;
15
15
  scrollTop(): number;
16
16
  setScrollTop(value: number): void;
17
+ /** Move by `delta`. The only relative write: see {@link withRelativeScroll}. */
18
+ scrollBy(delta: number): void;
17
19
  /** Fires on user and programmatic scrolls alike; returns the unsubscribe. */
18
20
  subscribe(onScroll: () => void): () => void;
19
21
  }
20
22
  export declare function createScrollport(target: UserScrollport): Scrollport;
23
+ /**
24
+ * Adds the relative write every corrector goes through. A scroller snaps a fractional write to
25
+ * a whole device pixel and reports the snapped value back, so a run of relative corrections (a
26
+ * mode flip fires one per re-measured block) would drop that fraction every time and slide the
27
+ * reader's content by the sum. The refused fraction carries into the next call instead.
28
+ */
29
+ export declare function withRelativeScroll(base: Omit<Scrollport, 'scrollBy'>): Scrollport;
@@ -1,5 +1,34 @@
1
1
  export function createScrollport(target) {
2
- return target === window ? pageScrollport() : elementScrollport(target);
2
+ return withRelativeScroll(target === window ? pageScrollport() : elementScrollport(target));
3
+ }
4
+ /**
5
+ * Adds the relative write every corrector goes through. A scroller snaps a fractional write to
6
+ * a whole device pixel and reports the snapped value back, so a run of relative corrections (a
7
+ * mode flip fires one per re-measured block) would drop that fraction every time and slide the
8
+ * reader's content by the sum. The refused fraction carries into the next call instead.
9
+ */
10
+ export function withRelativeScroll(base) {
11
+ let carried = 0;
12
+ let written = null;
13
+ return {
14
+ ...base,
15
+ setScrollTop(value) {
16
+ base.setScrollTop(value);
17
+ carried = 0;
18
+ written = null;
19
+ },
20
+ scrollBy(delta) {
21
+ const from = base.scrollTop();
22
+ // Anything that moved the port since our own write (the reader, a reveal) leaves the
23
+ // carried fraction describing a position nobody holds any more.
24
+ const target = from + (written === from ? carried : 0) + delta;
25
+ base.setScrollTop(target);
26
+ written = base.scrollTop();
27
+ const refused = target - written;
28
+ // Only the snap's own fraction carries; a clamp at either end is a real refusal.
29
+ carried = Math.abs(refused) < 1 ? refused : 0;
30
+ }
31
+ };
3
32
  }
4
33
  // ── Internal ───────────────────────────────────────────────────────────────
5
34
  function elementScrollport(el) {
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Whether the cursor sits on the first or last visual line of a wrapping element. Offsets
3
- * alone can't answer it past 2 wrapped lines, so cursor Y is compared to the first/last
4
- * line's Y. The text-node walk works around collapsed ranges next to non-text children
5
- * (dimmed marker spans) returning null rects — measuring around real text always works.
2
+ * Whether the cursor sits on the first or last visual line of a wrapping element. Offsets alone
3
+ * can't answer it past 2 wrapped lines, so the cursor's line is compared to the edge line's.
4
+ * Collapsed ranges beside non-text children (dimmed markers, atomic islands) measure to nothing,
5
+ * so the edge line is measured around real text, and a rect-less caret reads the box it sits
6
+ * against.
6
7
  */
7
8
  /** The first rect that can position a caret: the leading client rect when it has real
8
9
  * height, else the bounding rect. `widthTolerant` also accepts a zero-height rect with width. */