@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
@@ -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
  import { domDescendants } from './dom-walk';
8
9
  import { FALLBACK_LINE_HEIGHT } from './typography-estimates';
@@ -61,6 +62,7 @@ export function findLastTextNode(root) {
61
62
  export function isAtFirstVisualLine(el, fallbackOffset, contentStart) {
62
63
  return isAtEdgeVisualLine(el, () => fallbackOffset <= contentStart, {
63
64
  isEmpty: (el.textContent ?? '').length === 0,
65
+ toStart: true,
64
66
  boundaryTop: () => {
65
67
  const firstText = findFirstTextNode(el);
66
68
  const top = firstText ? getCharRangeTop(firstText, 0, false) : null;
@@ -71,6 +73,7 @@ export function isAtFirstVisualLine(el, fallbackOffset, contentStart) {
71
73
  export function isAtLastVisualLine(el, fallbackOffset, contentEnd) {
72
74
  return isAtEdgeVisualLine(el, () => fallbackOffset >= contentEnd, {
73
75
  isEmpty: contentEnd === 0,
76
+ toStart: false,
74
77
  boundaryTop: () => {
75
78
  const lastText = findLastTextNode(el);
76
79
  const top = lastText ? getCharRangeTop(lastText, lastText.textContent.length, true) : null;
@@ -80,7 +83,7 @@ export function isAtLastVisualLine(el, fallbackOffset, contentEnd) {
80
83
  }
81
84
  // ── Internal ────────────────────────────────────────────────────────────────
82
85
  /** The shared skeleton of the two edge predicates: `fallback` answers where geometry cannot — a
83
- * dropped range, an unmeasurable collapsed caret, an unmeasurable boundary line — and
86
+ * dropped range, a caret no box can be found for, an unmeasurable boundary line — and
84
87
  * `boundaryTop` measures the edge line each side's own way. */
85
88
  function isAtEdgeVisualLine(el, fallback, edge) {
86
89
  const sel = window.getSelection();
@@ -89,19 +92,61 @@ function isAtEdgeVisualLine(el, fallback, edge) {
89
92
  if (edge.isEmpty)
90
93
  return true;
91
94
  const cursorRange = sel.getRangeAt(0);
95
+ const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || FALLBACK_LINE_HEIGHT;
96
+ const tolerance = lineHeight * SAME_LINE_TOLERANCE;
92
97
  const cursorTop = getRangeTop(cursorRange);
93
- if (cursorTop === null)
94
- return cursorRange.collapsed ? fallback() : true;
98
+ if (cursorTop === null) {
99
+ if (!cursorRange.collapsed)
100
+ return true;
101
+ // A caret beside an atomic island sits at an element-level position and measures to no rect
102
+ // of its own: it rides the island's box, and is at the edge line when nothing reaches past.
103
+ const band = neighbourBand(cursorRange);
104
+ const contents = bandOfRange(contentsRange(el));
105
+ if (!band || !contents)
106
+ return fallback();
107
+ return edge.toStart
108
+ ? band.top < contents.top + tolerance
109
+ : band.bottom > contents.bottom - tolerance;
110
+ }
95
111
  const edgeTop = edge.boundaryTop();
96
112
  if (edgeTop === null)
97
113
  return fallback();
98
- const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || FALLBACK_LINE_HEIGHT;
99
- return Math.abs(cursorTop - edgeTop) < lineHeight * SAME_LINE_TOLERANCE;
114
+ return Math.abs(cursorTop - edgeTop) < tolerance;
100
115
  }
101
- /** The boundary line's collapsed-contents fallback measurement. */
102
- function collapsedContentsTop(el, toStart) {
116
+ /** The vertical band of the box a rect-less caret sits against: the child it precedes, else the
117
+ * one it follows. Null where the caret is not at an element-level position. */
118
+ function neighbourBand(range) {
119
+ const container = range.startContainer;
120
+ if (container.nodeType !== Node.ELEMENT_NODE)
121
+ return null;
122
+ const children = container.childNodes;
123
+ return (nodeBand(children[range.startOffset], false) ?? nodeBand(children[range.startOffset - 1], true));
124
+ }
125
+ function nodeBand(node, fromEnd) {
126
+ if (!node)
127
+ return null;
128
+ const range = document.createRange();
129
+ range.selectNode(node);
130
+ // A node that wraps has one rect per line, and the caret touches the line on its own side.
131
+ const rects = range.getClientRects();
132
+ if (rects.length === 0)
133
+ return bandOfRange(range);
134
+ return bandOf(rects[fromEnd ? rects.length - 1 : 0]);
135
+ }
136
+ function contentsRange(el) {
103
137
  const range = document.createRange();
104
138
  range.selectNodeContents(el);
139
+ return range;
140
+ }
141
+ function bandOfRange(range) {
142
+ return bandOf(range.getBoundingClientRect());
143
+ }
144
+ function bandOf(rect) {
145
+ return rect.height > 0 ? { top: rect.top, bottom: rect.bottom } : null;
146
+ }
147
+ /** The boundary line's collapsed-contents fallback measurement. */
148
+ function collapsedContentsTop(el, toStart) {
149
+ const range = contentsRange(el);
105
150
  range.collapse(toStart);
106
151
  return getRangeTop(range);
107
152
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Which atomic island's raw edge a point lands on: the NEAREST edge among the islands the caller
3
+ * measures, so a run of flush islands answers the one the point is actually beside. A point inside
4
+ * one reads its kind: a character-like island names the edge on the point's side, while an island
5
+ * that selects whole declines and keeps its own click handling.
6
+ */
7
+ export interface WidgetEdgeCandidate {
8
+ /** The island's own raw source span; the caret seats at one end or the other. */
9
+ start: number;
10
+ end: number;
11
+ rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>;
12
+ /** The kind reads as one character, so a press ON it names an edge by side rather than
13
+ * selecting the island whole. */
14
+ seatsInside: boolean;
15
+ }
16
+ export interface WidgetEdgeSeat {
17
+ offset: number;
18
+ /** The press landed ON the island: the engine answers such a point with a position in the
19
+ * neighbouring text, so its own caret is no reason to stand this seat down. */
20
+ inside: boolean;
21
+ }
22
+ /**
23
+ * The raw offset a point snaps to, in document order for ties. Null where the point snaps to none:
24
+ * inside an island that selects whole, whose own click handling owns it, or with no island to
25
+ * either side. A null `y` is a point with no line to compare against, so vertical distance drops
26
+ * out and horizontal containment alone reads as inside.
27
+ */
28
+ export declare function nearestWidgetEdgeSeat(candidates: Iterable<WidgetEdgeCandidate>, x: number, y: number | null): WidgetEdgeSeat | null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Which atomic island's raw edge a point lands on: the NEAREST edge among the islands the caller
3
+ * measures, so a run of flush islands answers the one the point is actually beside. A point inside
4
+ * one reads its kind: a character-like island names the edge on the point's side, while an island
5
+ * that selects whole declines and keeps its own click handling.
6
+ */
7
+ /**
8
+ * The raw offset a point snaps to, in document order for ties. Null where the point snaps to none:
9
+ * inside an island that selects whole, whose own click handling owns it, or with no island to
10
+ * either side. A null `y` is a point with no line to compare against, so vertical distance drops
11
+ * out and horizontal containment alone reads as inside.
12
+ */
13
+ export function nearestWidgetEdgeSeat(candidates, x, y) {
14
+ let best = null;
15
+ for (const { start, end, rect, seatsInside } of candidates) {
16
+ const withinRow = y === null || (rect.top < y && y < rect.bottom);
17
+ if (rect.left < x && x < rect.right && withinRow) {
18
+ if (!seatsInside)
19
+ return null;
20
+ return { offset: x < (rect.left + rect.right) / 2 ? start : end, inside: true };
21
+ }
22
+ // Rows before columns: a point past the end of one line must not reach an island on
23
+ // another line that happens to sit at the same x.
24
+ const rowGap = y === null ? 0 : Math.max(rect.top - y, y - rect.bottom, 0);
25
+ if (x <= rect.left)
26
+ best = nearer(best, { offset: start, rowGap, gap: rect.left - x });
27
+ if (x >= rect.right)
28
+ best = nearer(best, { offset: end, rowGap, gap: x - rect.right });
29
+ }
30
+ return best === null ? null : { offset: best.offset, inside: false };
31
+ }
32
+ function nearer(held, next) {
33
+ if (!held)
34
+ return next;
35
+ if (next.rowGap !== held.rowGap)
36
+ return next.rowGap < held.rowGap ? next : held;
37
+ return next.gap < held.gap ? next : held;
38
+ }
@@ -42,6 +42,9 @@ export declare function findDomTextOffsetTarget(container: ParentNode, target: D
42
42
  * against the ambient-adjusted argument.
43
43
  */
44
44
  export declare function widgetsIntersectingRange(container: HTMLElement, start: DomTextOffset, end: DomTextOffset): HTMLElement[];
45
+ /** The walk's text with marker chrome and widget bytes blanked to spaces, at the walk's own
46
+ * offsets: what a rule reading words (the multi-click ladder) sees as content. */
47
+ export declare function maskedWalkText(container: ParentNode): string;
45
48
  /** Total walk length of `container` — its one-past-end walk position. */
46
49
  export declare function containerDomTextLength(container: ParentNode): DomTextOffset;
47
50
  /**
@@ -111,6 +111,16 @@ export function widgetsIntersectingRange(container, start, end) {
111
111
  }
112
112
  return out;
113
113
  }
114
+ /** The walk's text with marker chrome and widget bytes blanked to spaces, at the walk's own
115
+ * offsets: what a rule reading words (the multi-click ladder) sees as content. */
116
+ export function maskedWalkText(container) {
117
+ let out = '';
118
+ for (const seg of walkSegments(container, null)) {
119
+ const content = seg.kind === 'text' && markerRootOf(seg.node, container) === null;
120
+ out += content ? (seg.node.textContent ?? '') : ' '.repeat(seg.len);
121
+ }
122
+ return out;
123
+ }
114
124
  /** Total walk length of `container` — its one-past-end walk position. */
115
125
  export function containerDomTextLength(container) {
116
126
  let count = 0;
@@ -18,6 +18,7 @@ export const RESERVED_BLOCK_ATTRS = new Set([
18
18
  'data-image-widget',
19
19
  'data-inline-widget',
20
20
  'data-link-card',
21
+ 'data-pointer-gesture',
21
22
  'data-presentation',
22
23
  'data-table-row-idx'
23
24
  ]);
@@ -3,7 +3,7 @@
3
3
  * No Svelte context or reactivity; inputs are passed in as parameters.
4
4
  */
5
5
  import { CURSOR_END, CURSOR_START } from '../../block-component';
6
- import { consumeStickyLanding } from './focus-landing';
6
+ import { consumeStickyLanding, verticalArrival } from './focus-landing';
7
7
  /** Move focus within a container, or delegate upward when out of range. */
8
8
  export async function dispatchMoveFocus(refs, innerIndex, position, stickyColumn, parent, scope = {}) {
9
9
  const { childCount, options, gapStop } = scope;
@@ -89,13 +89,16 @@ export function dispatchFocusAtColumn(refs, x, from) {
89
89
  if (refs.length === 0)
90
90
  return;
91
91
  const indices = from === 'above' ? refs.map((_, i) => i) : refs.map((_, i) => refs.length - 1 - i);
92
- // Skip vertically-transparent refs so an entry from above/below lands on the
93
- // first/last text-bearing child, not an image-only paragraph.
92
+ // Pass over transparent refs so an entry from above/below lands on the first/last
93
+ // text-bearing child.
94
94
  for (const i of indices) {
95
95
  const ref = refs[i];
96
96
  if (!ref?.focusable)
97
97
  continue;
98
- if (ref.isVerticallyTransparent?.())
98
+ const arrival = verticalArrival(ref, from);
99
+ if (arrival === 'entered')
100
+ return;
101
+ if (arrival === 'transparent')
99
102
  continue;
100
103
  if (ref.focusAtColumn)
101
104
  ref.focusAtColumn(x, from);
@@ -2,6 +2,15 @@
2
2
  * Shared cross-block landing tail for the focus dispatchers. Sticky-X null handling
3
3
  * lives here, so `focusAtColumn` receivers always get a finite x.
4
4
  */
5
- import { type BlockComponent, type FocusPosition } from '../../block-component';
5
+ import { type BlockComponent, type FocusPosition, type StickyColumnDirection } from '../../block-component';
6
6
  import type { StickyColumnState } from '../../cursor/sticky-column';
7
7
  export declare function consumeStickyLanding(block: BlockComponent, index: number, position: FocusPosition, stickyColumn: StickyColumnState, retryAt: (index: number) => Promise<void> | void): Promise<void>;
8
+ /** What a vertical arrival does at a block: enter its widget, pass over it, or seat a caret. */
9
+ export type VerticalArrival = 'entered' | 'transparent' | 'seat';
10
+ /**
11
+ * The vertical stop rule, asked by both vertical doors: the per-block landing and a container's
12
+ * column entry. A widget-only block carries no column, so it is passed over unless its edge widget
13
+ * takes the arrival, which is a stop of its own and reads alike from either side. `'entered'` means
14
+ * the widget ALREADY took it, so the caller stops rather than repeating the entry.
15
+ */
16
+ export declare function verticalArrival(block: BlockComponent, from: StickyColumnDirection): VerticalArrival;
@@ -5,12 +5,15 @@
5
5
  import { CURSOR_END, CURSOR_START } from '../../block-component';
6
6
  export async function consumeStickyLanding(block, index, position, stickyColumn, retryAt) {
7
7
  const isStickyMove = typeof position === 'object' && 'stickyColumnFrom' in position;
8
- // Widget-only blocks contribute no column landing, so ArrowUp/Down passes through.
9
- // Horizontal moves still stop at the widget edge.
10
- if (isStickyMove && block.isVerticallyTransparent?.()) {
11
- const direction = position.stickyColumnFrom === 'below' ? -1 : 1;
12
- await retryAt(index + direction);
13
- return;
8
+ if (isStickyMove) {
9
+ const from = position.stickyColumnFrom;
10
+ const arrival = verticalArrival(block, from);
11
+ if (arrival === 'entered')
12
+ return;
13
+ if (arrival === 'transparent') {
14
+ await retryAt(index + (from === 'below' ? -1 : 1));
15
+ return;
16
+ }
14
17
  }
15
18
  // Enter an edge widget rather than dropping a no-op caret at its boundary, so the
16
19
  // arrow key produces one visible step.
@@ -37,3 +40,14 @@ export async function consumeStickyLanding(block, index, position, stickyColumn,
37
40
  else
38
41
  block.focus(CURSOR_END);
39
42
  }
43
+ /**
44
+ * The vertical stop rule, asked by both vertical doors: the per-block landing and a container's
45
+ * column entry. A widget-only block carries no column, so it is passed over unless its edge widget
46
+ * takes the arrival, which is a stop of its own and reads alike from either side. `'entered'` means
47
+ * the widget ALREADY took it, so the caller stops rather than repeating the entry.
48
+ */
49
+ export function verticalArrival(block, from) {
50
+ if (!block.isVerticallyTransparent?.())
51
+ return 'seat';
52
+ return block.enterEdgeWidget?.(from === 'above' ? 'start' : 'end') ? 'entered' : 'transparent';
53
+ }
@@ -87,6 +87,13 @@ export interface ContainerBlock {
87
87
  * entry and windowing reveal. False for a modified or non-arrow key: leave it native.
88
88
  */
89
89
  moveFocusOut(e: KeyboardEvent): boolean;
90
+ /**
91
+ * The reader's scroll position, captured now. Call before a state flip that swaps this
92
+ * block's view for one of a different height, then await the returned restore: the
93
+ * scrollport is back where it was once the swap has flushed. A reveal in flight outranks
94
+ * it and the restore stands down.
95
+ */
96
+ captureScrollPosition(): () => Promise<void>;
90
97
  }
91
98
  /**
92
99
  * Collapse-ness has ONE definition: the descriptor's `reservedChrome.isCollapsed`
@@ -13,6 +13,7 @@ import { eventToChord } from '../../schema/keybindings';
13
13
  import { isReadingMode } from '../../presentation-mode';
14
14
  import { devWarn } from '../../dev-warn';
15
15
  import { BLOCK_EDIT_KEY, CONTAINER_EDIT_KEY, EDITOR_DOC_KEY, EDITOR_POLICIES_KEY, EDITOR_SERVICES_KEY, FOCUS_KEY, HISTORY_KEY } from '../../editor-keys';
16
+ import { captureScrollPosition } from '../../cursor/scroll-hold';
16
17
  import { emitCommandError } from '../../editor-events';
17
18
  import { owningPluginEditor } from '../../schema/plugin-install';
18
19
  import { createBlockListState } from '../../reactivity/block-list-state.svelte';
@@ -130,7 +131,7 @@ export function createContainerBlock(deps) {
130
131
  const parentFocus = getContext(FOCUS_KEY);
131
132
  const parentContainerEdit = getContext(CONTAINER_EDIT_KEY);
132
133
  const history = getContext(HISTORY_KEY);
133
- const { stickyColumn, edgeAffinity, selection, reorder, events: editorEvents, registryView, activePlugins } = getContext(EDITOR_SERVICES_KEY);
134
+ const { stickyColumn, edgeAffinity, selection, reorder, revealAnchor, events: editorEvents, registryView, activePlugins } = getContext(EDITOR_SERVICES_KEY);
134
135
  const { keybindingOverrides, presentationMode: getPresentationMode, theme: getTheme } = getContext(EDITOR_POLICIES_KEY);
135
136
  const editorDoc = getContext(EDITOR_DOC_KEY);
136
137
  const pluginEditor = editorDoc?.pluginEditor;
@@ -344,6 +345,7 @@ export function createContainerBlock(deps) {
344
345
  updateOwnMetadata,
345
346
  handleKeydown,
346
347
  moveFocusOut,
348
+ captureScrollPosition: () => captureScrollPosition(deps.getBoxEl(), () => revealAnchor.get() !== null),
347
349
  getPresentationMode,
348
350
  getTheme,
349
351
  getOptions
@@ -47,6 +47,9 @@ export interface EditorProps {
47
47
  * available, as is the table's right-click cell menu. */
48
48
  blockDragHandles?: boolean;
49
49
  searchBar?: boolean;
50
+ /** The editor's own formatting popover beside a prose selection (default on; reading mode
51
+ * never shows it). A host with its own bar over `runCommand` passes false. */
52
+ selectionToolbar?: boolean;
50
53
  /** Where the editor's own find/replace bar renders. Default (absent) keeps it pinned inside
51
54
  * the editor root; an element relocates the SAME bar into it, theme scope included, so
52
55
  * host-scroll embeds can put it in a pane's chrome instead of mid-page. Read live, and
package/dist/plugin.d.ts CHANGED
@@ -29,7 +29,7 @@ export type { OuterBlockScan } from './schema/block-openers';
29
29
  export { OPENER_PRIORITIES } from './schema/opener-priorities';
30
30
  export { registerBlockCompleter } from './schema/block-completions';
31
31
  export type { BlockCompleter, CompletionResult } from './schema/block-completions';
32
- export { registerLanguage, listLanguages } from './components/blocks/code/code-languages';
32
+ export { registerLanguage, listLanguages, getLanguageAliases } from './components/blocks/code/code-languages';
33
33
  export type { LanguageGrammar } from './components/blocks/code/code-languages';
34
34
  export { tokenizeBody as highlightCode } from './components/blocks/code/code-renderer';
35
35
  export type { LanguageFn } from 'highlight.js';
@@ -103,6 +103,7 @@ export { caretOffsetAtPoint } from './cursor/point-offset';
103
103
  export type { CaretTarget } from './schema/block-kind-descriptor';
104
104
  export { CURSOR_END } from './block-component';
105
105
  export type { CursorEnd } from './block-component';
106
+ export { POINTER_GESTURE_ATTR } from './selection/pointer-gesture';
106
107
  export type { EditorSelection, SelectionPoint } from './selection/primitives';
107
108
  export { SELECTION_END } from './block-component';
108
109
  export type { SelectionEnd } from './block-component';
package/dist/plugin.js CHANGED
@@ -43,7 +43,8 @@ export { registerBlockCompleter } from './schema/block-completions';
43
43
  // grammar is bundle weight for every consumer), so a host needing more registers them itself,
44
44
  // BEFORE mounting an editor: a block on screen re-tokenizes only when its bytes next change.
45
45
  // An unregistered language is not an error — the fence still round-trips, just untokenized.
46
- export { registerLanguage, listLanguages } from './components/blocks/code/code-languages';
46
+ // `listLanguages` lists each language once; `getLanguageAliases` holds the other spellings.
47
+ export { registerLanguage, listLanguages, getLanguageAliases } from './components/blocks/code/code-languages';
47
48
  // The code block's own tokenizer, for a plugin whose source surface wants the same highlighting
48
49
  // (block math paints its LaTeX with it). Text-preserving: the fragment's textContent is `body`.
49
50
  export { tokenizeBody as highlightCode } from './components/blocks/code/code-renderer';
@@ -164,4 +165,8 @@ export { registerPasteTransform } from './tree-operations/paste/paste-transforms
164
165
  // names one), and the sentinel for "wherever this leaf ends".
165
166
  export { caretOffsetAtPoint } from './cursor/point-offset';
166
167
  export { CURSOR_END } from './block-component';
168
+ // ── Pointer gestures (pre-freeze) ────────────────────────────────────────────
169
+ // Put this attribute on an element whose drags are your widget's own (a pan, a brush) and the
170
+ // editor's pointer arms leave a press inside it alone: no block range, no click ladder.
171
+ export { POINTER_GESTURE_ATTR } from './selection/pointer-gesture';
167
172
  export { SELECTION_END } from './block-component';
@@ -13,37 +13,44 @@ export const MATH_FENCE = 'mathFence';
13
13
  // ── Recognition ──────────────────────────────────────────────────────────────
14
14
  const isWhitespace = (ch) => /\s/.test(ch);
15
15
  const isDigit = (ch) => ch >= '0' && ch <= '9';
16
- function indexMathClosers(raw) {
16
+ function indexDollars(raw) {
17
17
  const positions = [];
18
18
  for (let i = 1; i < raw.length; i++) {
19
- if (raw[i] === '$' && !isWhitespace(raw[i - 1]))
19
+ if (raw[i] === '$')
20
20
  positions.push(i);
21
21
  }
22
22
  return Int32Array.from(positions);
23
23
  }
24
24
  // Indexed once per block, not searched per consultation: a paragraph of shell prose
25
25
  // (`$HOME $PATH $USER …`) would otherwise cost a full block scan at every `$`.
26
- const firstCloserFrom = createScanIndex(indexMathClosers);
26
+ const nextDollarFrom = createScanIndex(indexDollars);
27
+ /** Money, written the way prose writes it: a whole number between the delimiters. */
28
+ const isPriceSpan = (body) => /^\d[\d.,]*$/.test(body);
27
29
  /**
28
- * The digit guard on the opener is what keeps `$5 and $10` currency, not math. The
29
- * close is deliberately not digit-guarded, so `$x^2$` closes on its `2`.
30
+ * Pandoc's rule, with one divergence of ours. An attempt ENDS at the next `$`, whichever it is,
31
+ * and a bad closer leaves the opener literal; a closer needs a non-space before it and no digit
32
+ * after it. Ours: a span that is only a number is a price (`$5$`), so a typed price stays prose.
30
33
  */
31
34
  function recognizeMath(raw, pos, end, kind) {
32
35
  const afterOpen = pos + 1;
33
36
  if (afterOpen >= end)
34
37
  return null;
35
38
  const opener = raw[afterOpen];
36
- if (isWhitespace(opener) || isDigit(opener))
39
+ if (isWhitespace(opener))
37
40
  return null;
38
41
  // `$$` is the display fence, or the empty pair a keystroke just closed: never an inline
39
- // opener, or its closer search would jump to the far end of the next formula on the line.
42
+ // opener, or the attempt would end on its own twin.
40
43
  if (opener === '$')
41
44
  return null;
42
45
  // The index spans the whole block, so `end` decides the claim: a closer past the
43
46
  // scan range leaves the `$` literal.
44
- const close = firstCloserFrom(raw, pos + 2);
47
+ const close = nextDollarFrom(raw, pos + 2);
45
48
  if (close === -1 || close >= end)
46
49
  return null;
50
+ if (isWhitespace(raw[close - 1]) || isDigit(raw[close + 1] ?? ''))
51
+ return null;
52
+ if (isPriceSpan(raw.slice(afterOpen, close)))
53
+ return null;
47
54
  return { kind, start: pos, end: close + 1 };
48
55
  }
49
56
  // ── Registration ─────────────────────────────────────────────────────────────
@@ -61,12 +68,42 @@ export function registerMathInline() {
61
68
  component: MathInline,
62
69
  editing: {
63
70
  revealSource: true,
64
- // `$…$`: one delimiter each side, so a revealing click seats the caret on the last
65
- // character of the formula rather than past its closing `$`.
66
- revealContentSpan: (source) => source.length >= 2 ? { start: 1, end: source.length - 1 } : null
71
+ revealContentSpan: mathContentSpan,
72
+ revealOffsetAtPoint: mathInlineOffsetAtPoint
67
73
  }
68
74
  });
69
75
  }
76
+ // ── Caret from a press ───────────────────────────────────────────────────────
77
+ // KaTeX paints glyphs, not source bytes, so both forms read a press the same way: how far along
78
+ // the painted run it fell, walked proportionally into the span the run renders.
79
+ /** `$…$`: one delimiter each side, so an edit stays inside the formula. */
80
+ const mathContentSpan = (source) => source.length >= 2 ? { start: 1, end: source.length - 1 } : null;
81
+ function glyphOffsetInSpan(rendered, span, clientX, clientY) {
82
+ // `.katex-html` is the painted half: its MathML twin is clipped to a pixel, and the pair
83
+ // measured together answers for a point no reader aimed at.
84
+ const glyphs = rendered.querySelector('.katex-html');
85
+ const along = glyphs ? caretOffsetAtPoint(glyphs, clientX, clientY) : null;
86
+ const total = glyphs?.textContent?.length ?? 0;
87
+ if (along === null || total === 0)
88
+ return null;
89
+ return span.start + Math.round((along / total) * (span.end - span.start));
90
+ }
91
+ function mathInlineOffsetAtPoint(widgetEl, source, clientX, clientY) {
92
+ const span = mathContentSpan(source);
93
+ return span ? glyphOffsetInSpan(widgetEl, span, clientX, clientY) : null;
94
+ }
95
+ /** Where a press on the folded equation puts the caret; the fence lines carry no glyph of
96
+ * their own, so a point the glyphs cannot answer for keeps the body's end. */
97
+ function mathCaretAtPoint(blockEl, clientX, clientY) {
98
+ const render = blockEl.querySelector('.math-block-render');
99
+ if (!render)
100
+ return null;
101
+ const start = Number(render.dataset.bodyStart);
102
+ const end = Number(render.dataset.bodyEnd);
103
+ if (!Number.isFinite(start) || !Number.isFinite(end))
104
+ return null;
105
+ return { path: [], offset: glyphOffsetInSpan(render, { start, end }, clientX, clientY) ?? end };
106
+ }
70
107
  // ── Rendered display source ────────────────────────────────────────────────────
71
108
  /**
72
109
  * Shared by the render component so `mathBlock` and `mathFence` display identically.
@@ -167,26 +204,6 @@ export function registerMathBlock() {
167
204
  // Co-registered so one install teaches both forms (the admonition/githubAlert precedent).
168
205
  registerMathFence();
169
206
  }
170
- /** Where a press on the folded equation puts the caret. KaTeX paints glyphs, not source bytes,
171
- * so the press walks the body span in proportion to how far along the equation it fell; the
172
- * fence lines carry no glyph of their own. */
173
- function mathCaretAtPoint(blockEl, clientX, clientY) {
174
- const render = blockEl.querySelector('.math-block-render');
175
- if (!render)
176
- return null;
177
- const start = Number(render.dataset.bodyStart);
178
- const end = Number(render.dataset.bodyEnd);
179
- if (!Number.isFinite(start) || !Number.isFinite(end))
180
- return null;
181
- // `.katex-html` is the painted half: its MathML twin is clipped to a pixel, and the pair
182
- // measured together answers for a point no reader aimed at.
183
- const glyphs = render.querySelector('.katex-html');
184
- const along = glyphs ? caretOffsetAtPoint(glyphs, clientX, clientY) : null;
185
- const total = glyphs?.textContent?.length ?? 0;
186
- if (along === null || total === 0)
187
- return { path: [], offset: end };
188
- return { path: [], offset: start + Math.round((along / total) * (end - start)) };
189
- }
190
207
  // ── Fenced ```math display math ─────────────────────────────────────────────────
191
208
  // GitHub's third math form: a source-holding leaf like the `$$` block, rendered by
192
209
  // the same component.
@@ -7,10 +7,10 @@ export declare function mathBodySpan(text: string): {
7
7
  end: number;
8
8
  };
9
9
  /**
10
- * A `$$` block with no body LINE — `$$$$`, `$$\n$$`, a whitespace-only one-liner — has nowhere
10
+ * A block with no body LINE — `$$$$`, `$$\n$$`, a ```math straight over its closer — has nowhere
11
11
  * for a caret to sit once the fence lines hide, and Backspace has no byte it could mean. The
12
- * completion every such block takes as its source is revealed: opener, one empty body line,
13
- * closer, caret on that line. A block that already has a body line, blank or not, is left alone.
12
+ * completion every such block takes: opener, one empty body line, closer, caret on that line,
13
+ * rebuilt from its OWN delimiters. A block that has a body line, blank or not, is left alone.
14
14
  */
15
15
  export declare function completeBareMathSource(text: string): {
16
16
  text: string;
@@ -1,38 +1,51 @@
1
1
  /**
2
- * The `$$` block's source as DOM, shaped like a code block's: each fence line in a
2
+ * The block form's source as DOM, shaped like a code block's: each fence line in a
3
3
  * `.md-fence-line` wrapper the marker-hiding modes collapse, the body as LaTeX highlight
4
4
  * tokens. Text-preserving by construction — opener + body + closer is the input — so the
5
5
  * offset walk and G1.28 hold. Anything not shaped like a fence paints as plain tokens.
6
6
  */
7
- import { highlightCode } from '../../plugin';
7
+ import { highlightCode, matchFenceClose, matchFenceOpen } from '../../plugin';
8
8
  const FENCE = '$$';
9
+ const unsliced = (text) => ({ opener: '', body: text, closer: '' });
10
+ /** Both block forms are an opener line, a body and a closer line; only the closer test differs,
11
+ * since GitHub's ```math carries its info string on the opener and closes on a bare fence. */
9
12
  function sliceMathSource(text) {
10
- if (!text.startsWith(FENCE))
11
- return { opener: '', body: text, closer: '' };
12
13
  const firstNewline = text.indexOf('\n');
13
- // One-line form: `$$x^2$$`.
14
- if (firstNewline === -1) {
15
- if (text.length >= 4 && text.endsWith(FENCE)) {
16
- return { opener: FENCE, body: text.slice(2, -2), closer: FENCE };
14
+ if (text.startsWith(FENCE)) {
15
+ // One-line form: `$$x^2$$`.
16
+ if (firstNewline === -1) {
17
+ return text.length >= 4 && text.endsWith(FENCE)
18
+ ? { opener: FENCE, body: text.slice(2, -2), closer: FENCE }
19
+ : unsliced(text);
17
20
  }
18
- return { opener: '', body: text, closer: '' };
21
+ if (text.slice(0, firstNewline) !== FENCE)
22
+ return unsliced(text);
23
+ return sliceFenceLines(text, firstNewline, (line) => line === FENCE);
19
24
  }
20
- if (text.slice(0, firstNewline) !== FENCE)
21
- return { opener: '', body: text, closer: '' };
25
+ if (firstNewline === -1)
26
+ return unsliced(text);
27
+ // CommonMark's fence rules stay the editor's: a closer repeats its opener's own marker, at
28
+ // that length or longer.
29
+ const fence = matchFenceOpen(text.slice(0, firstNewline));
30
+ if (!fence)
31
+ return unsliced(text);
32
+ return sliceFenceLines(text, firstNewline, (line) => matchFenceClose(line, fence.marker, fence.length));
33
+ }
34
+ function sliceFenceLines(text, firstNewline, isCloser) {
22
35
  const opener = text.slice(0, firstNewline + 1);
23
36
  const rest = text.slice(opener.length);
24
37
  const lastNewline = rest.lastIndexOf('\n');
25
38
  const lastLine = lastNewline === -1 ? rest : rest.slice(lastNewline + 1);
26
- if (lastLine !== FENCE)
39
+ if (!isCloser(lastLine))
27
40
  return { opener, body: rest, closer: '' };
28
41
  if (lastNewline === -1)
29
- return { opener, body: '', closer: FENCE };
42
+ return { opener, body: '', closer: lastLine };
30
43
  // The newline before the closer belongs to the closer's line (the code block's rule), so
31
44
  // collapsing that line leaves no blank line at the box's edge — unless the body is blank,
32
45
  // where that newline IS the one line the caret can sit on.
33
46
  const bodyWithEnding = rest.slice(0, lastNewline + 1);
34
47
  if (!/\S/.test(bodyWithEnding))
35
- return { opener, body: bodyWithEnding, closer: FENCE };
48
+ return { opener, body: bodyWithEnding, closer: lastLine };
36
49
  return { opener, body: rest.slice(0, lastNewline), closer: rest.slice(lastNewline) };
37
50
  }
38
51
  function fenceLine(text) {
@@ -53,21 +66,14 @@ function fenceLine(text) {
53
66
  * a point measured against the rendered equation names a place in this span and nowhere else.
54
67
  */
55
68
  export function mathBodySpan(text) {
56
- if (/^[ \t]*(?:`{3,}|~{3,})/.test(text)) {
57
- const firstBreak = text.indexOf('\n');
58
- if (firstBreak === -1)
59
- return { start: text.length, end: text.length };
60
- const closer = /(?:\r?\n)?[ \t]*(?:`{3,}|~{3,})[ \t]*\r?\n?$/.exec(text);
61
- return { start: firstBreak + 1, end: closer ? closer.index : text.length };
62
- }
63
69
  const { opener, body } = sliceMathSource(text);
64
70
  return { start: opener.length, end: opener.length + body.length };
65
71
  }
66
72
  /**
67
- * A `$$` block with no body LINE — `$$$$`, `$$\n$$`, a whitespace-only one-liner — has nowhere
73
+ * A block with no body LINE — `$$$$`, `$$\n$$`, a ```math straight over its closer — has nowhere
68
74
  * for a caret to sit once the fence lines hide, and Backspace has no byte it could mean. The
69
- * completion every such block takes as its source is revealed: opener, one empty body line,
70
- * closer, caret on that line. A block that already has a body line, blank or not, is left alone.
75
+ * completion every such block takes: opener, one empty body line, closer, caret on that line,
76
+ * rebuilt from its OWN delimiters. A block that has a body line, blank or not, is left alone.
71
77
  */
72
78
  export function completeBareMathSource(text) {
73
79
  const { opener, body, closer } = sliceMathSource(text);
@@ -75,8 +81,8 @@ export function completeBareMathSource(text) {
75
81
  return null;
76
82
  if (body.includes('\n') || body.trim() !== '')
77
83
  return null;
78
- const completed = `${FENCE}\n\n${FENCE}`;
79
- return { text: completed, caret: FENCE.length + 1 };
84
+ const openerLine = opener.replace(/\n$/, '');
85
+ return { text: `${openerLine}\n\n${closer}`, caret: openerLine.length + 1 };
80
86
  }
81
87
  export function renderMathSource(text) {
82
88
  const { opener, body, closer } = sliceMathSource(text);