@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.
- package/dist/a11y-strings.d.ts +1 -0
- package/dist/a11y-strings.js +1 -0
- package/dist/components/BlockDragHandle.svelte +2 -0
- package/dist/components/BlockHost.svelte +2 -2
- package/dist/components/Editor.svelte +56 -3
- package/dist/components/SelectionOverlay.svelte +17 -23
- package/dist/components/SelectionOverlay.svelte.d.ts +1 -1
- package/dist/components/blocks/code/CodeBlockRail.svelte +42 -24
- package/dist/components/blocks/code/code-bootstrap.js +12 -0
- package/dist/components/blocks/code/code-languages.d.ts +5 -5
- package/dist/components/blocks/code/code-languages.js +20 -12
- package/dist/components/blocks/editable-leaf.d.ts +3 -3
- package/dist/components/blocks/editable-leaf.js +6 -2
- package/dist/components/blocks/list/ListBlock.svelte +1 -0
- package/dist/components/blocks/list/ListItemBlock.svelte +12 -3
- package/dist/components/blocks/list/ListItemBlock.svelte.d.ts +2 -0
- package/dist/components/blocks/text/click-snap-guard.d.ts +3 -0
- package/dist/components/blocks/text/click-snap-guard.js +8 -0
- package/dist/components/blocks/text/edge-policy-dispatch.js +33 -18
- package/dist/components/blocks/text/live-selection-edit.d.ts +5 -5
- package/dist/components/blocks/text/live-selection-edit.js +10 -7
- package/dist/components/blocks/text/text-clipboard.js +2 -2
- package/dist/components/blocks/text/widget-interaction.js +45 -23
- package/dist/components/drag-handle.d.ts +6 -0
- package/dist/components/drag-handle.js +8 -0
- package/dist/components/editor-root-geometry.d.ts +1 -1
- package/dist/components/editor-root-geometry.js +1 -1
- package/dist/components/editor-root-listeners.d.ts +0 -7
- package/dist/components/editor-root-listeners.js +0 -22
- package/dist/components/image/ImageResizeHandles.svelte +18 -10
- package/dist/components/menu/SelectionToolbar.svelte +385 -0
- package/dist/components/menu/SelectionToolbar.svelte.d.ts +16 -0
- package/dist/core/inline/inline-widgets.d.ts +12 -4
- package/dist/core/inline/inline-widgets.js +5 -0
- package/dist/core/inline/transparency.js +3 -3
- package/dist/cursor/height-oracle.d.ts +2 -3
- package/dist/cursor/height-oracle.js +0 -1
- package/dist/cursor/scroll-hold.d.ts +10 -0
- package/dist/cursor/scroll-hold.js +22 -0
- package/dist/cursor/scrollport.d.ts +9 -0
- package/dist/cursor/scrollport.js +30 -1
- package/dist/cursor/visual-lines.d.ts +5 -4
- package/dist/cursor/visual-lines.js +56 -11
- package/dist/cursor/widget-edge-snap.d.ts +28 -0
- package/dist/cursor/widget-edge-snap.js +38 -0
- package/dist/cursor/widget-offset.d.ts +3 -0
- package/dist/cursor/widget-offset.js +10 -0
- package/dist/decorations/reserved-attrs.js +1 -0
- package/dist/editor-actions/focus/focus-dispatch.js +7 -4
- package/dist/editor-actions/focus/focus-landing.d.ts +10 -1
- package/dist/editor-actions/focus/focus-landing.js +20 -6
- package/dist/editor-actions/plugin/container.d.ts +7 -0
- package/dist/editor-actions/plugin/container.js +3 -1
- package/dist/editor-props.d.ts +3 -0
- package/dist/plugin.d.ts +2 -1
- package/dist/plugin.js +6 -1
- package/dist/plugins/latex/latex-kind.js +48 -31
- package/dist/plugins/latex/math-source.d.ts +3 -3
- package/dist/plugins/latex/math-source.js +32 -26
- package/dist/plugins/mermaid/MermaidBlock.svelte +19 -9
- package/dist/plugins/parrot/ParrotBlock.svelte +3 -1
- package/dist/reactivity/list-windowing.svelte.js +26 -6
- package/dist/schema/commands.d.ts +9 -5
- package/dist/schema/commands.js +12 -7
- package/dist/schema/operations.d.ts +1 -1
- package/dist/schema/reserved-chords.js +12 -0
- package/dist/selection/cross-block/keydown.js +2 -28
- package/dist/selection/cross-block/paste.js +7 -13
- package/dist/selection/cross-block/type-replace.js +5 -15
- package/dist/selection/dead-space-caret.d.ts +3 -0
- package/dist/selection/dead-space-caret.js +12 -2
- package/dist/selection/drag-pointer.d.ts +19 -0
- package/dist/selection/drag-pointer.js +42 -0
- package/dist/selection/keyboard-extend.d.ts +3 -2
- package/dist/selection/keyboard-extend.js +3 -2
- package/dist/selection/multi-click.d.ts +42 -0
- package/dist/selection/multi-click.js +203 -0
- package/dist/selection/native-bridge.d.ts +3 -0
- package/dist/selection/native-bridge.js +31 -2
- package/dist/selection/path-lookup.d.ts +10 -2
- package/dist/selection/path-lookup.js +10 -3
- package/dist/selection/pointer-gesture.d.ts +9 -0
- package/dist/selection/pointer-gesture.js +11 -0
- package/dist/selection/primitives.d.ts +7 -0
- package/dist/selection/primitives.js +19 -1
- package/dist/selection/range-delete.d.ts +4 -0
- package/dist/selection/range-delete.js +1 -1
- package/dist/selection/selection-drop.d.ts +34 -0
- package/dist/selection/selection-drop.js +253 -0
- package/dist/styles/editor-theme.css +18 -3
- package/dist/styles/editor.css +64 -13
- package/dist/tree-operations/paste/replace-block-at-parent.d.ts +3 -1
- package/dist/tree-operations/paste/replace-block-at-parent.js +4 -1
- package/dist/tree-operations/paste/replacement-parse.d.ts +17 -0
- package/dist/tree-operations/paste/replacement-parse.js +22 -0
- package/docs/guide/consumer-guide.md +10 -8
- package/docs/guide/plugin-api.md +54 -38
- package/docs/guide/plugin-guide.md +31 -22
- package/package.json +7 -7
- package/dist/selection/double-click-trim.d.ts +0 -17
- 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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
return Math.abs(cursorTop - edgeTop) < lineHeight * SAME_LINE_TOLERANCE;
|
|
114
|
+
return Math.abs(cursorTop - edgeTop) < tolerance;
|
|
100
115
|
}
|
|
101
|
-
/** The
|
|
102
|
-
|
|
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;
|
|
@@ -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
|
-
//
|
|
93
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
package/dist/editor-props.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
16
|
+
function indexDollars(raw) {
|
|
17
17
|
const positions = [];
|
|
18
18
|
for (let i = 1; i < raw.length; i++) {
|
|
19
|
-
if (raw[i] === '$'
|
|
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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
return
|
|
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
|
-
|
|
21
|
+
if (text.slice(0, firstNewline) !== FENCE)
|
|
22
|
+
return unsliced(text);
|
|
23
|
+
return sliceFenceLines(text, firstNewline, (line) => line === FENCE);
|
|
19
24
|
}
|
|
20
|
-
if (
|
|
21
|
-
return
|
|
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
|
|
39
|
+
if (!isCloser(lastLine))
|
|
27
40
|
return { opener, body: rest, closer: '' };
|
|
28
41
|
if (lastNewline === -1)
|
|
29
|
-
return { opener, body: '', closer:
|
|
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:
|
|
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
|
|
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
|
|
70
|
-
*
|
|
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
|
|
79
|
-
return { text:
|
|
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);
|