@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
@@ -32,6 +32,7 @@
32
32
  updateOwnMetadata,
33
33
  handleKeydown,
34
34
  moveFocusOut,
35
+ captureScrollPosition,
35
36
  getPresentationMode,
36
37
  getTheme
37
38
  } = createContainerBlock({
@@ -142,22 +143,20 @@
142
143
  const view = createPanZoom();
143
144
  const overlayView = createPanZoom();
144
145
 
145
- // Focus-gated so the in-document diagram never hijacks the page: unfocused, a bare
146
- // wheel scrolls and a stray drag cannot pan. Any descendant focus counts.
147
- const isFocused = () => !!boxEl?.contains(document.activeElement);
146
+ // Armed by focus, so an unfocused diagram hijacks neither the page nor the editor's own drag.
147
+ // Tracked rather than probed: the editor reads the same fact as markup, so there is one value.
148
+ let gestureArmed = $state(false);
148
149
 
149
150
  function onViewportWheel(e: WheelEvent): void {
150
- if (!isFocused() || !(e.ctrlKey || e.metaKey)) return;
151
+ if (!gestureArmed || !(e.ctrlKey || e.metaKey)) return;
151
152
  e.preventDefault();
152
153
  e.stopPropagation();
153
154
  view.zoomBy(e.deltaY);
154
155
  }
155
156
 
156
157
  function onViewportPointerDown(e: PointerEvent): void {
157
- // Never preventDefault, so the browser's focus-on-mousedown still lands: the
158
- // first click focuses, and only a drag on the now-focused block pans.
159
158
  e.stopPropagation();
160
- if (isFocused()) view.beginPan(e);
159
+ if (gestureArmed) view.beginPan(e);
161
160
  }
162
161
 
163
162
  function onOverlayWheel(e: WheelEvent): void {
@@ -219,8 +218,11 @@
219
218
  // there too, so this closes the command path.
220
219
  if (isReading) return;
221
220
  if (!editing) seedDraft();
221
+ // Captured BEFORE the flip: the card reaches its fitted height only after mounting, and
222
+ // a scrollport at the document's end clamps against the short layout in between.
223
+ const restoreScroll = captureScrollPosition();
222
224
  editRequested = true;
223
- void tick().then(() => textareaEl?.focus());
225
+ void restoreScroll().then(() => textareaEl?.focus());
224
226
  }
225
227
 
226
228
  function cancelEdit(): void {
@@ -306,7 +308,13 @@
306
308
  </script>
307
309
 
308
310
  <!-- svelte-ignore a11y_no_static_element_interactions -->
309
- <div class="mermaid-block" bind:this={boxEl} onkeydown={handleKeydown}>
311
+ <div
312
+ class="mermaid-block"
313
+ bind:this={boxEl}
314
+ onkeydown={handleKeydown}
315
+ onfocusin={() => (gestureArmed = true)}
316
+ onfocusout={(e) => (gestureArmed = boxEl?.contains(e.relatedTarget as Node | null) ?? false)}
317
+ >
310
318
  {#if editing}
311
319
  <textarea
312
320
  bind:this={textareaEl}
@@ -330,6 +338,7 @@
330
338
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
331
339
  <div
332
340
  class="mermaid-viewport"
341
+ data-pointer-gesture={gestureArmed ? '' : undefined}
333
342
  tabindex="0"
334
343
  role="img"
335
344
  aria-label="Mermaid diagram"
@@ -392,6 +401,7 @@
392
401
  </div>
393
402
  <div
394
403
  class="mermaid-overlay-viewport"
404
+ data-pointer-gesture
395
405
  onwheel={onOverlayWheel}
396
406
  onpointerdown={(e) => {
397
407
  e.stopPropagation();
@@ -273,7 +273,9 @@ cNd.........................................;lOc
273
273
  font-size: 1.1em;
274
274
  line-height: 1.1;
275
275
  letter-spacing: 0.05em;
276
- /* one frame tall, in the reel's own rows so a step lands on the next frame exactly */
276
+ /* one frame tall, in the reel's own rows so a step lands on the next frame exactly; the
277
+ em line is the same height for engines without lh (Safari before 16.4) */
278
+ height: calc(var(--parrot-rows) * 1.1em);
277
279
  height: calc(var(--parrot-rows) * 1lh);
278
280
  /* wider than a phone column, and the editor root pans if it isn't contained; the bar
279
281
  would sit across the bird, which is decoration rather than a pane to scroll */
@@ -43,7 +43,23 @@ export function createListWindowing(deps) {
43
43
  // structural rebuild needs the OLD ordering to remap the anchor by id, and by then
44
44
  // `getChildIds()` already reflects the new children. Copied — it is spliced in place.
45
45
  let modelChildIds = [];
46
- function buildModel() {
46
+ /** What the CURRENT model holds, keyed by id, for a rebuild to carry across. The two lengths
47
+ * diverge for one tick after a child change, and a slot past the model's end reads 0. */
48
+ function heightsById() {
49
+ const carried = new Map();
50
+ const known = Math.min(modelChildIds.length, model.size);
51
+ for (let i = 0; i < known; i++)
52
+ carried.set(modelChildIds[i], model.heightOf(i));
53
+ return carried;
54
+ }
55
+ /**
56
+ * A surviving block keeps the height the model measured (VR-15); `carried` is null only
57
+ * where a width or type-scale change has earned a reseed from the oracle. The cache behind
58
+ * those heights can be gone (a mode flip drops it, and a block whose box never moved reports
59
+ * no resize to put it back), so reseeding a structural rebuild would trade a whole document
60
+ * of measurements for estimates and scroll the reader by the difference.
61
+ */
62
+ function buildModel(carried) {
47
63
  const width = estimateWidth(deps.getListEl(), deps.getPort()?.contentWidth() ?? 0);
48
64
  const children = deps.getChildren();
49
65
  // Indexed, and off the snapshot: `map` pays a `has` trap beside every `get`, once per child.
@@ -51,11 +67,13 @@ export function createListWindowing(deps) {
51
67
  const count = children.length;
52
68
  const heights = new Array(count);
53
69
  for (let i = 0; i < count; i++) {
54
- heights[i] = deps.oracle.height(modelChildIds[i], children[i], width);
70
+ const id = modelChildIds[i];
71
+ heights[i] =
72
+ deps.oracle.measured(id) ?? carried?.get(id) ?? deps.oracle.estimate(children[i], width);
55
73
  }
56
74
  return new HeightModel(heights);
57
75
  }
58
- let model = $state(buildModel());
76
+ let model = $state(buildModel(null));
59
77
  let heightVersion = $state(0);
60
78
  // One scope-owned batched pass rather than a per-child effect, which would interleave a
61
79
  // layout read with the prior child's write → one forced reflow per mounted block on a
@@ -153,7 +171,7 @@ export function createListWindowing(deps) {
153
171
  mutate();
154
172
  const delta = model.offsetOf(anchorIndex) - before;
155
173
  if (delta !== 0 && port)
156
- port.setScrollTop(port.scrollTop() + delta);
174
+ port.scrollBy(delta);
157
175
  }
158
176
  // The structural-rebuild variant of correctAnchor. A count change shifts every index
159
177
  // above it, so the numeric form would measure a DIFFERENT block at index N and mis-correct
@@ -182,7 +200,7 @@ export function createListWindowing(deps) {
182
200
  return;
183
201
  const delta = model.offsetOf(newIndex) - before;
184
202
  if (delta !== 0 && port)
185
- port.setScrollTop(port.scrollTop() + delta);
203
+ port.scrollBy(delta);
186
204
  }
187
205
  let lastWidthVersion = deps.getWidthVersion();
188
206
  // Rebuild on any structural child change or an editor WIDTH change, never per keystroke.
@@ -202,8 +220,10 @@ export function createListWindowing(deps) {
202
220
  // the re-measure compares measured-before against all-estimate-after, and lands the
203
221
  // anchor a block off wherever the poisoned model names a different top child (#188).
204
222
  // Width only — re-measuring on a structural edit costs a reflow per split.
223
+ // Read off the OLD model, so the carry is taken before the rebuild replaces it.
224
+ const carried = widthChanged ? null : heightsById();
205
225
  correctAnchorByStableId(() => {
206
- model = buildModel();
226
+ model = buildModel(carried);
207
227
  heightVersion++;
208
228
  if (widthChanged)
209
229
  remeasureMounted();
@@ -19,10 +19,10 @@ export type GlobalCommandId = (typeof GLOBAL_COMMAND_IDS)[number];
19
19
  export type BlockCommandId = (typeof BLOCK_COMMAND_IDS)[number];
20
20
  export type CommandId = GlobalCommandId | BlockCommandId;
21
21
  /**
22
- * Commands whose arms rewrite one block's bytes around that block's own selection and have no
23
- * cross-block reading: the dispatch seam declines them outright while a range is painted.
24
- * Membership is the arm's shape, not the id's prefix — the link card mints its link over one
25
- * block's offsets, and a range gives it no one block to mint into.
22
+ * Commands whose arms rewrite one block and have no cross-block reading: the dispatch seam
23
+ * declines them outright while a range is painted. Membership is the arm's shape, not the id's
24
+ * prefix: the link card mints over one block's offsets and a heading level belongs to one block,
25
+ * so a range spanning blocks leaves neither of them a subject to act on.
26
26
  */
27
27
  export declare const RANGE_DECLINED_COMMAND_IDS: ReadonlySet<string>;
28
28
  /**
@@ -34,7 +34,9 @@ export declare const RANGE_DECLINED_COMMAND_IDS: ReadonlySet<string>;
34
34
  export declare const CROSS_BLOCK_RANGE_COMMAND_IDS: ReadonlySet<string>;
35
35
  /**
36
36
  * The command ids a host's selection toolbar invokes through `EditorInstance.runCommand`. The
37
- * rest of the vocabulary stays internal until the command registry unifies it.
37
+ * rest of the vocabulary stays internal until the command registry unifies it. Every id here
38
+ * answers the range question in one of the two sets above, so `canRunCommand` tells a bar which
39
+ * of its buttons a cross-block selection leaves nothing to act on.
38
40
  */
39
41
  export declare const TOOLBAR_COMMANDS: {
40
42
  readonly toggleStrong: "format.toggleStrong";
@@ -43,6 +45,8 @@ export declare const TOOLBAR_COMMANDS: {
43
45
  readonly toggleCode: "format.toggleCode";
44
46
  /** The link editor Mod+K opens: over a selection it creates, inside a link it edits. */
45
47
  readonly editLink: "link.openCard";
48
+ /** The heading picker's arm, taking the level as its argument; 0 is normal text. */
49
+ readonly setHeading: "heading.cycle";
46
50
  };
47
51
  /** Minimal context a global command needs; HistoryActions is structurally compatible. */
48
52
  export interface GlobalCommandContext {
@@ -44,13 +44,14 @@ export const BLOCK_COMMAND_IDS = [
44
44
  'chrome.descendToBody'
45
45
  ];
46
46
  /**
47
- * Commands whose arms rewrite one block's bytes around that block's own selection and have no
48
- * cross-block reading: the dispatch seam declines them outright while a range is painted.
49
- * Membership is the arm's shape, not the id's prefix — the link card mints its link over one
50
- * block's offsets, and a range gives it no one block to mint into.
47
+ * Commands whose arms rewrite one block and have no cross-block reading: the dispatch seam
48
+ * declines them outright while a range is painted. Membership is the arm's shape, not the id's
49
+ * prefix: the link card mints over one block's offsets and a heading level belongs to one block,
50
+ * so a range spanning blocks leaves neither of them a subject to act on.
51
51
  */
52
52
  export const RANGE_DECLINED_COMMAND_IDS = new Set([
53
- 'link.openCard'
53
+ 'link.openCard',
54
+ 'heading.cycle'
54
55
  ]);
55
56
  /**
56
57
  * The same one-block arms, but with a cross-block one behind them
@@ -66,7 +67,9 @@ export const CROSS_BLOCK_RANGE_COMMAND_IDS = new Set([
66
67
  ]);
67
68
  /**
68
69
  * The command ids a host's selection toolbar invokes through `EditorInstance.runCommand`. The
69
- * rest of the vocabulary stays internal until the command registry unifies it.
70
+ * rest of the vocabulary stays internal until the command registry unifies it. Every id here
71
+ * answers the range question in one of the two sets above, so `canRunCommand` tells a bar which
72
+ * of its buttons a cross-block selection leaves nothing to act on.
70
73
  */
71
74
  export const TOOLBAR_COMMANDS = {
72
75
  toggleStrong: 'format.toggleStrong',
@@ -74,7 +77,9 @@ export const TOOLBAR_COMMANDS = {
74
77
  toggleStrikethrough: 'format.toggleStrikethrough',
75
78
  toggleCode: 'format.toggleCode',
76
79
  /** The link editor Mod+K opens: over a selection it creates, inside a link it edits. */
77
- editLink: 'link.openCard'
80
+ editLink: 'link.openCard',
81
+ /** The heading picker's arm, taking the level as its argument; 0 is normal text. */
82
+ setHeading: 'heading.cycle'
78
83
  };
79
84
  const globalCommands = new Map();
80
85
  export function registerCommand(id, run) {
@@ -39,7 +39,7 @@ export interface OperationDetailMap {
39
39
  parentItemIdx?: number;
40
40
  nestedItemIdx?: number;
41
41
  } | {
42
- source: 'paste-dispatch' | 'paste-dispatch-table-cell' | 'cross-block-covered-block';
42
+ source: 'paste-dispatch' | 'paste-dispatch-table-cell' | 'cross-block-covered-block' | 'selection-drop';
43
43
  };
44
44
  paste: {
45
45
  count: number;
@@ -27,6 +27,18 @@ export const HARDCODED_CHORD_SITES = [
27
27
  keys: [],
28
28
  note: 'The Mod-held `data-mod-active` affordance tracks flag state only — no keystroke is consumed.'
29
29
  },
30
+ {
31
+ file: 'selection/selection-drop.ts',
32
+ chords: [],
33
+ keys: [],
34
+ note: 'Ctrl/Alt at the DROP is the platform copy modifier, read off a DragEvent — no keystroke is consumed.'
35
+ },
36
+ {
37
+ file: 'selection/multi-click.ts',
38
+ chords: [],
39
+ keys: [],
40
+ note: 'A modified multi-click is left to the browser (Shift extends, Mod activates) — no keystroke is consumed.'
41
+ },
30
42
  {
31
43
  file: 'components/GapCaret.svelte',
32
44
  chords: [],
@@ -11,9 +11,7 @@ import { dispatchKeyCommand } from '../../schema/block-commands';
11
11
  import { collapseCrossBlock, extendFocusToNextBlock, extendFocusToPreviousBlock, extendFocusToDocEdge, selectWholeDocument, scrollFocusBlockIntoView } from '../keyboard-extend';
12
12
  import { pathsEqual } from '../path-math';
13
13
  import { intraTableRectExtension } from '../table-rect-extend';
14
- import { ambientSpanOf, placeCaretAfterAmbientSpan } from '../../ambient/ambient-dom';
15
- import { asDomTextOffset } from '../../cursor/coordinate-spaces';
16
- import { createRangeFromOffsets } from '../../cursor/content-offsets';
14
+ import { applySurfaceContentRange } from '../native-bridge';
17
15
  export function createCrossBlockKeydown(ctx, mutCtx) {
18
16
  return {
19
17
  handleKeyDown: (e) => handleKeyDown(ctx, mutCtx, e),
@@ -168,7 +166,7 @@ async function handleCrossBlockEntry(ctx, e) {
168
166
  e.preventDefault();
169
167
  selection.incrementSelectAllCount();
170
168
  if (selection.selectAllCount === 1) {
171
- selectFirstPressContent(el);
169
+ applySurfaceContentRange(el);
172
170
  return true;
173
171
  }
174
172
  selectWholeDocument(selection, getDoc(), ctx.getBlockElByPath);
@@ -261,30 +259,6 @@ function kindOfPath(path, doc) {
261
259
  // The root's 'document' kind is outside AnyBlockKind; dispatch treats it as an unknown kind.
262
260
  return isBlockNode(node) ? node.kind : node.kind;
263
261
  }
264
- /**
265
- * Select the block's content for the first Ctrl+A press. With an ambient marker (a list item's
266
- * `- `), anchor after it so type-replace doesn't corrupt the contenteditable="false" island.
267
- */
268
- function selectFirstPressContent(el) {
269
- const ambient = ambientSpanOf(el);
270
- const ambientLen = ambient?.textContent?.length ?? 0;
271
- const textLen = el.textContent?.length ?? 0;
272
- if (ambient && textLen > ambientLen) {
273
- if (!placeCaretAfterAmbientSpan(el))
274
- return;
275
- // textLen counts the full textContent (marker included) — a DomTextOffset by construction.
276
- const endRange = createRangeFromOffsets(el, asDomTextOffset(textLen), asDomTextOffset(textLen));
277
- if (endRange) {
278
- window.getSelection()?.extend(endRange.endContainer, endRange.endOffset);
279
- }
280
- return;
281
- }
282
- const range = document.createRange();
283
- range.selectNodeContents(el);
284
- const sel = window.getSelection();
285
- sel?.removeAllRanges();
286
- sel?.addRange(range);
287
- }
288
262
  /**
289
263
  * Bring the active (focus-side) endpoint into view after an extend. A cell-coordinate focus
290
264
  * addresses the table block by cell index, so reveal the deep cell path to mount the off-window
@@ -5,18 +5,17 @@
5
5
  */
6
6
  import { tableCellCount } from '../table-endpoint-snap';
7
7
  import { CURSOR_END } from '../../block-component';
8
- import { normalizeLineEndings, terminateLine } from '../../core/lines';
8
+ import { normalizeLineEndings } from '../../core/lines';
9
9
  import { performCrossBlockDelete } from './ops';
10
10
  import { charOffsetOf } from '../primitives';
11
11
  import { focusCollapsedCaret } from '../native-bridge';
12
12
  import { blockCoveredWhole } from '../covered-block';
13
13
  import { pasteDispatch } from '../../tree-operations/paste/dispatch';
14
14
  import { applyPasteTransforms } from '../../tree-operations/paste/paste-transforms';
15
- import { parse } from '../../core/parser';
16
15
  import { blockNodeAt, isBlockNode, nodeAt } from '../../tree-operations/node-primitives';
17
16
  import { pathsEqual } from '../path-math';
18
17
  import { replaceBlockAtParent } from '../../tree-operations/paste/replace-block-at-parent';
19
- import { ensureEditableContainers, normalizeReplacementTrivia } from '../../tree-operations';
18
+ import { parseReplacement } from '../../tree-operations/paste/replacement-parse';
20
19
  import { emitClipboardError } from '../../editor-events';
21
20
  export async function handleCrossBlockPaste(ctx, mutCtx, e, replacement) {
22
21
  if (!ctx.selection.isCrossBlock)
@@ -130,24 +129,19 @@ async function replaceCoveredBlockWithPaste(ctx, mutCtx, pasted, blockPath) {
130
129
  if (!covered)
131
130
  return;
132
131
  // This route never reaches pasteDispatch, so the paste transforms and the instance grammar
133
- // ride here too; both rules live in the helper, applied at both sites. Terminated in the
134
- // covered block's OWN ending, or an unterminated clipboard line leaves the block below
135
- // flowing into the last one pasted (G4.20).
136
- const parsed = parse(terminateLine(applyPasteTransforms(pasted, ctx.activePlugins), covered.raw), { grammar: ctx.grammar, scope: 'fragment' });
137
- if (parsed.children.length === 0)
132
+ // ride here too; both rules live in the helper, applied at both sites.
133
+ const parsed = parseReplacement(covered, applyPasteTransforms(pasted, ctx.activePlugins), ctx.grammar);
134
+ if (!parsed)
138
135
  return;
139
- const replacement = normalizeReplacementTrivia(covered, parsed.children);
140
- for (const node of replacement)
141
- ensureEditableContainers(node);
142
136
  mutCtx.pushUndoSnapshot();
143
137
  ctx.selection.collapse();
144
138
  await replaceBlockAtParent({
145
139
  doc,
146
140
  blockPath,
147
- replacement,
141
+ replacement: parsed.replacement,
148
142
  controller: ctx.pasteCoordinator,
149
143
  undoEntry: 'join',
150
- focusReplacementIndex: replacement.length - 1,
144
+ focusReplacementIndex: parsed.replacement.length - 1,
151
145
  focusOffset: CURSOR_END,
152
146
  source: 'cross-block-covered-block',
153
147
  ...(ctx.grammar ? { grammar: ctx.grammar } : {}),
@@ -10,10 +10,8 @@ import { performCrossBlockDelete } from './ops';
10
10
  import { charOffsetOf } from '../primitives';
11
11
  import { blockCoveredWhole } from '../covered-block';
12
12
  import { CURSOR_END } from '../../block-component';
13
- import { parse } from '../../core/parser';
14
- import { terminateLine } from '../../core/lines';
15
13
  import { replaceBlockAtParent } from '../../tree-operations/paste/replace-block-at-parent';
16
- import { ensureEditableContainers, normalizeReplacementTrivia } from '../../tree-operations';
14
+ import { parseReplacement } from '../../tree-operations/paste/replacement-parse';
17
15
  import { blockNodeAt, normalizeBodyWrite, writeOwnRaw } from '../../tree-operations/node-primitives';
18
16
  import { updateNodeContent, settledCaretTarget } from '../../tree-operations/content-write';
19
17
  import { focusCollapsedCaret } from '../native-bridge';
@@ -123,26 +121,18 @@ async function replaceCoveredBlockWithText(ctx, mutCtx, typed, blockPath) {
123
121
  const covered = blockNodeAt(doc, blockPath);
124
122
  if (!covered)
125
123
  return;
126
- // Terminated in the block's OWN ending: an unterminated line leaves the block below flowing
127
- // into the one the character just minted (G4.20).
128
- const parsed = parse(terminateLine(typed, covered.raw), {
129
- grammar: ctx.grammar,
130
- scope: 'fragment'
131
- });
132
- if (parsed.children.length === 0)
124
+ const parsed = parseReplacement(covered, typed, ctx.grammar);
125
+ if (!parsed)
133
126
  return;
134
- const replacement = normalizeReplacementTrivia(covered, parsed.children);
135
- for (const node of replacement)
136
- ensureEditableContainers(node);
137
127
  mutCtx.pushUndoSnapshot();
138
128
  ctx.selection.collapse();
139
129
  await replaceBlockAtParent({
140
130
  doc,
141
131
  blockPath,
142
- replacement,
132
+ replacement: parsed.replacement,
143
133
  controller: ctx.pasteCoordinator,
144
134
  undoEntry: 'join',
145
- focusReplacementIndex: replacement.length - 1,
135
+ focusReplacementIndex: parsed.replacement.length - 1,
146
136
  focusOffset: CURSOR_END,
147
137
  source: 'cross-block-covered-block',
148
138
  ...(ctx.grammar ? { grammar: ctx.grammar } : {})
@@ -45,5 +45,8 @@ export interface DeadSpaceCaret {
45
45
  * whichever way the drag goes. A coordinate-addressed kind (a table) anchors nothing.
46
46
  */
47
47
  anchorAtPoint(root: HTMLElement, x: number, y: number): SelectionEndpoint | null;
48
+ /** The path of the block a press names, the point clamped into the nearest band: what the
49
+ * click ladder selects in for a press on the margin or a block's own box. */
50
+ blockPathNearPoint(root: HTMLElement, x: number, y: number): number[] | null;
48
51
  }
49
52
  export declare function createDeadSpaceCaret(deps: DeadSpaceCaretDeps): DeadSpaceCaret;
@@ -88,7 +88,8 @@ export function createDeadSpaceCaret(deps) {
88
88
  leafOf(component, landing.path)?.snapCaretToPoint?.(probeX, probeY);
89
89
  return true;
90
90
  }
91
- function anchorAtPoint(root, x, y) {
91
+ /** The nearest block's hit for a press, with the probe point clamped into its band. */
92
+ function hitNearPoint(root, x, y) {
92
93
  const blocks = measureBlocks(root);
93
94
  const band = nearestBand(blocks.map((b) => b.rect), y);
94
95
  if (!band)
@@ -97,8 +98,13 @@ export function createDeadSpaceCaret(deps) {
97
98
  return null;
98
99
  const { x: probeX, y: probeY } = probePointIn(blocks[band.index].rect, x, y, band.belowAll);
99
100
  const hit = blockAtPoint(root, probeX, probeY);
100
- if (!hit)
101
+ return hit && { hit, probeX, probeY };
102
+ }
103
+ function anchorAtPoint(root, x, y) {
104
+ const near = hitNearPoint(root, x, y);
105
+ if (!near)
101
106
  return null;
107
+ const { hit, probeX, probeY } = near;
102
108
  // No character surface to measure against — a rendered equation, a rule, a table — so the
103
109
  // block is the unit, and the funnel picks the side by the drag's direction. An offset would
104
110
  // put a range END at the block's start and leave it out; a cell would make the drag a caret
@@ -112,9 +118,13 @@ export function createDeadSpaceCaret(deps) {
112
118
  return { path: hit.path.slice(), wholeBlock: true };
113
119
  return { path: hit.path.slice(), offset: landing.offset };
114
120
  }
121
+ function blockPathNearPoint(root, x, y) {
122
+ return hitNearPoint(root, x, y)?.hit.path.slice() ?? null;
123
+ }
115
124
  return {
116
125
  isDeadSpaceTarget: isDeadSpace,
117
126
  anchorAtPoint,
127
+ blockPathNearPoint,
118
128
  notePress(root, event) {
119
129
  pressedOnDeadSpace =
120
130
  isDeadSpace(root, event.target) &&
@@ -7,6 +7,7 @@ import type { UserScrollport } from '../cursor/scroll-ancestors';
7
7
  import type { SelectionState } from './selection-state.svelte';
8
8
  import type { SelectionEndpoint } from './primitives';
9
9
  import type { BlockElLookup } from '../editor-keys';
10
+ import { type SelectionPoint } from './primitives';
10
11
  export interface DragContext {
11
12
  editorRoot: HTMLElement;
12
13
  /** What autoscrolls this drag: an element, or the window (`cursor/scroll-ancestors`). */
@@ -20,6 +21,24 @@ export interface DragContext {
20
21
  * is extending a selection underneath: the session paints the same-block range itself.
21
22
  */
22
23
  paintSameBlock?: boolean;
24
+ /** A drag continuing a multi-click: the range grows by that rung's unit. */
25
+ granularity?: DragGranularity;
26
+ }
27
+ /** The unit a multi-click drag grows by. Spans are raw offsets; the press's own span is the
28
+ * floor the range never shrinks below. */
29
+ export interface DragGranularity {
30
+ /** Where the press selected; a pointer that names no other block paints here. */
31
+ surface: HTMLElement;
32
+ anchorSpan: {
33
+ start: number;
34
+ end: number;
35
+ };
36
+ spanAround(offset: number): {
37
+ start: number;
38
+ end: number;
39
+ };
40
+ /** A focus in another block, pushed to the unit's boundary on the side away from the anchor. */
41
+ expandFocus(point: SelectionPoint, side: 'before' | 'after'): SelectionEndpoint;
23
42
  }
24
43
  /** Document-level pointer listeners for a cross-block drag started at `down`, plus a disposer. */
25
44
  export declare function installDragListener(ctx: DragContext, anchorPoint: SelectionEndpoint, down: PointerEvent): {
@@ -3,6 +3,7 @@
3
3
  * `createPointerDragSession`, whose document-level listeners deliver events even after the
4
4
  * pointer leaves the originating block.
5
5
  */
6
+ import { caretOffsetAtPoint } from '../cursor/point-offset';
6
7
  import { applyCollapsedCaret, applySingleBlockRange, clearNativeSelection } from './native-bridge';
7
8
  import { isWholeBlockEndpoint } from './primitives';
8
9
  import { comparePaths } from './path-math';
@@ -18,6 +19,10 @@ export function installDragListener(ctx, anchorPoint, down) {
18
19
  const near = blockNearPoint(ctx.editorRoot, clientX, clientY);
19
20
  if (!near)
20
21
  return;
22
+ if (ctx.granularity) {
23
+ processGranularMove(ctx.granularity, near, clientX, clientY);
24
+ return;
25
+ }
21
26
  if (comparePaths(near.path, anchorPoint.path) === 0) {
22
27
  if (!('offset' in anchorPoint)) {
23
28
  if (ctx.paintSameBlock)
@@ -48,6 +53,43 @@ export function installDragListener(ctx, anchorPoint, down) {
48
53
  ctx.selection.extendFocus(focusPoint);
49
54
  }
50
55
  }
56
+ // The anchor's side flips with the drag's direction (the press's span ends the range going up,
57
+ // starts it going down), so a flip re-enters; a same-side move only extends.
58
+ let anchorAfter = null;
59
+ function processGranularMove(unit, near, clientX, clientY) {
60
+ const anchorIsChar = 'offset' in anchorPoint && !anchorPoint.cellCoordinate;
61
+ if (!anchorIsChar || comparePaths(near.path, anchorPoint.path) === 0) {
62
+ if (ctx.selection.isCrossBlock)
63
+ ctx.selection.collapse();
64
+ anchorAfter = null;
65
+ const offset = caretOffsetAtPoint(unit.surface, clientX, clientY);
66
+ if (offset === null)
67
+ return;
68
+ const span = unit.spanAround(offset);
69
+ unit.surface.focus({ preventScroll: true });
70
+ applySingleBlockRange(unit.surface, Math.min(unit.anchorSpan.start, span.start), Math.max(unit.anchorSpan.end, span.end));
71
+ return;
72
+ }
73
+ const focusPoint = near.endpointHere();
74
+ if (!focusPoint)
75
+ return;
76
+ if (isWholeBlockEndpoint(focusPoint) && !reachedCentreLine(near.path, clientY))
77
+ return;
78
+ const after = comparePaths(near.path, anchorPoint.path) > 0;
79
+ const focus = isWholeBlockEndpoint(focusPoint)
80
+ ? focusPoint
81
+ : unit.expandFocus(focusPoint, after ? 'after' : 'before');
82
+ if (!ctx.selection.isCrossBlock || anchorAfter !== after) {
83
+ anchorAfter = after;
84
+ ctx.selection.enterCrossBlock({
85
+ path: anchorPoint.path.slice(),
86
+ offset: after ? unit.anchorSpan.start : unit.anchorSpan.end
87
+ }, focus);
88
+ }
89
+ else {
90
+ ctx.selection.extendFocus(focus);
91
+ }
92
+ }
51
93
  // A block with no positions inside it is in or out as a unit, and the unit joins the range
52
94
  // once the pointer has crossed its centre line coming from the anchor's side: a sweep that
53
95
  // merely touched its edge has not asked for it. Not reached, the last focus stands, so the
@@ -18,8 +18,9 @@ export declare function scrollFocusBlockIntoView(selection: SelectionState, getB
18
18
  /**
19
19
  * Extend focus to the next leaf in document order (Shift+ArrowDown / Shift+ArrowRight leaving
20
20
  * the block), entering cross-block mode if needed. Returns true if focus moved. `axis` =
21
- * 'vertical' skips vertically-transparent leaves, mirroring single-block focus dispatch;
22
- * 'horizontal' lands unconditionally so an image-only paragraph is selectable in one step.
21
+ * 'vertical' skips vertically-transparent leaves — a range covers one either way, so unlike a
22
+ * collapsed caret it needs no object stop; 'horizontal' lands unconditionally so an image-only
23
+ * paragraph is selectable in one step.
23
24
  */
24
25
  export declare function extendFocusToNextBlock(selection: SelectionState, doc: Document, currentBlockEl: HTMLElement, currentBlockPath: number[], axis?: 'horizontal' | 'vertical', getBlockElByPath?: BlockElLookup): boolean;
25
26
  /**
@@ -79,8 +79,9 @@ function extendFocusOrRestore(selection, target, offset, getBlockElByPath) {
79
79
  /**
80
80
  * Extend focus to the next leaf in document order (Shift+ArrowDown / Shift+ArrowRight leaving
81
81
  * the block), entering cross-block mode if needed. Returns true if focus moved. `axis` =
82
- * 'vertical' skips vertically-transparent leaves, mirroring single-block focus dispatch;
83
- * 'horizontal' lands unconditionally so an image-only paragraph is selectable in one step.
82
+ * 'vertical' skips vertically-transparent leaves — a range covers one either way, so unlike a
83
+ * collapsed caret it needs no object stop; 'horizontal' lands unconditionally so an image-only
84
+ * paragraph is selectable in one step.
84
85
  */
85
86
  export function extendFocusToNextBlock(selection, doc, currentBlockEl, currentBlockPath, axis = 'horizontal', getBlockElByPath) {
86
87
  const leafTarget = axis === 'vertical'
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The click ladder past the caret: the second press of a run selects the word under it, the
3
+ * third the surface's content, and a drag from either extends at that granularity. Owned from
4
+ * the second press, so the browser paints nothing of its own: its word rule is per platform,
5
+ * its walk enters atomic islands, and a marker here never joins a word.
6
+ */
7
+ import type { UserScrollport } from '../cursor/scroll-ancestors';
8
+ import type { BlockElLookup } from '../editor-keys';
9
+ import type { SelectionState } from './selection-state.svelte';
10
+ export type ClickGranularity = 'word' | 'block';
11
+ export interface Span {
12
+ start: number;
13
+ end: number;
14
+ }
15
+ /**
16
+ * [start, end) of the segment at `offset` in `text`: the word ending there when it is one, else
17
+ * the segment starting there (whitespace or punctuation, as the engine would take). Null on
18
+ * empty text, or on a platform with no segmenter.
19
+ */
20
+ export declare function wordSpanAt(text: string, offset: number): Span | null;
21
+ /** The raw span `granularity` takes around a raw offset in `surface`; null where the word rule
22
+ * has nothing to read. */
23
+ export declare function spanAround(surface: HTMLElement, granularity: ClickGranularity, rawOffset: number): Span | null;
24
+ /** The rung a press's click count reaches; the run stays at the block rung past three. */
25
+ export declare function granularityForClickCount(clickCount: number): ClickGranularity | null;
26
+ export interface MultiClickDeps {
27
+ editorRoot: HTMLElement;
28
+ selection: SelectionState;
29
+ getBlockElByPath: BlockElLookup;
30
+ getScrollContainer(): UserScrollport;
31
+ lifetimeSignal?: AbortSignal;
32
+ /** A press outside every editable surface (the margin, a container's own box) names the
33
+ * nearest block, as a single click there does; null where the press is chrome's. */
34
+ marginBlockAt(target: EventTarget | null, clientX: number, clientY: number): number[] | null;
35
+ }
36
+ /**
37
+ * Root listeners for the ladder. The count rides the mousedown (a pointer event carries none),
38
+ * and the drag rides the pointerdown of the same press, so both are heard. The mousedown is
39
+ * cancelled, never the pointerdown (that would silence the click), and so is the mouseup: the
40
+ * browser's release seats a caret at a press that landed off a glyph, over the range just painted.
41
+ */
42
+ export declare function installMultiClickSelect(deps: MultiClickDeps): () => void;