@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
|
@@ -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
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
package/dist/schema/commands.js
CHANGED
|
@@ -44,13 +44,14 @@ export const BLOCK_COMMAND_IDS = [
|
|
|
44
44
|
'chrome.descendToBody'
|
|
45
45
|
];
|
|
46
46
|
/**
|
|
47
|
-
* Commands whose arms rewrite one block
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
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 {
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
22
|
-
* 'horizontal' lands unconditionally so an image-only
|
|
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,
|
|
83
|
-
* 'horizontal' lands unconditionally so an image-only
|
|
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;
|