edytor 6.0.0 → 7.0.0
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/README.md +3 -3
- package/dist/cloudflare/DocumentRoom.d.ts +77 -2
- package/dist/cloudflare/DocumentRoom.js +37 -1
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/index.js +1 -1
- package/dist/cloudflare/room/access.d.ts +6 -1
- package/dist/cloudflare/room/access.js +30 -4
- package/dist/cloudflare/room/admission.d.ts +11 -0
- package/dist/cloudflare/room/admission.js +43 -10
- package/dist/cloudflare/room/context.d.ts +11 -2
- package/dist/cloudflare/room/context.js +17 -6
- package/dist/cloudflare/room/forged.d.ts +1 -1
- package/dist/cloudflare/room/forged.js +11 -9
- package/dist/cloudflare/room/frames.d.ts +2 -0
- package/dist/cloudflare/room/frames.js +2 -0
- package/dist/cloudflare/room/presence.js +1 -4
- package/dist/cloudflare/room/reseed.d.ts +93 -0
- package/dist/cloudflare/room/reseed.js +500 -0
- package/dist/cloudflare/room/scheduler.d.ts +1 -1
- package/dist/cloudflare/room/scheduler.js +9 -1
- package/dist/cloudflare/room/shared.d.ts +10 -0
- package/dist/cloudflare/room/shared.js +10 -0
- package/dist/cloudflare/room/storage.d.ts +7 -0
- package/dist/cloudflare/room/storage.js +33 -1
- package/dist/components/Edytor.svelte +18 -3
- package/dist/components/Edytor.svelte.d.ts +10 -1
- package/dist/components/EdytorRoot.svelte +17 -0
- package/dist/components/EdytorRoot.svelte.d.ts +4 -0
- package/dist/components/Suggestion.svelte +1 -1
- package/dist/crdt/attribution/block.d.ts +3 -0
- package/dist/crdt/attribution/block.js +3 -2
- package/dist/crdt/doc/suggest.d.ts +6 -2
- package/dist/crdt/doc/suggest.js +6 -5
- package/dist/crdt/edytor-doc.d.ts +1 -1
- package/dist/crdt/edytor-doc.js +1 -1
- package/dist/crdt/engine-room.d.ts +4 -0
- package/dist/crdt/engine-room.js +6 -0
- package/dist/crdt/index.d.ts +2 -1
- package/dist/crdt/protocol.d.ts +2 -2
- package/dist/crdt/protocol.js +2 -2
- package/dist/crdt/protocols/auth.d.ts +8 -1
- package/dist/crdt/protocols/auth.js +14 -1
- package/dist/crdt/providers/index.d.ts +1 -0
- package/dist/crdt/providers/index.js +22 -3
- package/dist/crdt/providers/room.d.ts +15 -0
- package/dist/crdt/providers/room.js +15 -0
- package/dist/crdt/providers/websocket.d.ts +4 -2
- package/dist/crdt/providers/websocket.js +36 -9
- package/dist/crdt/reseed.d.ts +127 -0
- package/dist/crdt/reseed.js +533 -0
- package/dist/crdt/restore.d.ts +13 -1
- package/dist/crdt/restore.js +8 -3
- package/dist/crdt/schema.d.ts +6 -0
- package/dist/crdt/schema.js +6 -0
- package/dist/crdt/structs.d.ts +8 -11
- package/dist/crdt/structs.js +25 -44
- package/dist/crdt/text/index/build.d.ts +3 -9
- package/dist/crdt/text/index/build.js +87 -181
- package/dist/crdt/vendor/yjs/UPSTREAM.md +101 -5
- package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +10 -10
- package/dist/crdt/vendor/yjs/dts/utils/encoding.d.ts +1 -0
- package/dist/crdt/vendor/yjs/src/index.js +1 -1
- package/dist/crdt/vendor/yjs/src/structs/Item.js +24 -13
- package/dist/crdt/vendor/yjs/src/utils/Transaction.js +12 -10
- package/dist/crdt/vendor/yjs/src/utils/encoding.js +42 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/selection/selection.svelte.d.ts +1 -1
- package/dist/selection/selection.svelte.js +87 -106
- package/dist/session/commands.js +49 -51
- package/dist/surface/attributes.d.ts +3 -3
- package/dist/surface/attributes.js +2 -2
- package/dist/surface/observer.svelte.d.ts +7 -7
- package/dist/surface/observer.svelte.js +213 -309
- package/package.json +1 -1
|
@@ -16,19 +16,18 @@ import { caretBeside, shownText } from './replaceSelection.js';
|
|
|
16
16
|
* order), comparing boundary points — a focus on an ancestor element of the
|
|
17
17
|
* anchor (`(textElement, childCount)`) is after it.
|
|
18
18
|
*/
|
|
19
|
-
export const isBackward = (
|
|
20
|
-
const { anchorNode, focusNode } = selection;
|
|
19
|
+
export const isBackward = ({ anchorNode, anchorOffset, focusNode, focusOffset }) => {
|
|
21
20
|
if (!anchorNode || !focusNode)
|
|
22
21
|
return false;
|
|
23
22
|
if (anchorNode === focusNode)
|
|
24
|
-
return
|
|
23
|
+
return focusOffset < anchorOffset;
|
|
25
24
|
try {
|
|
26
25
|
const range = createDomRange(anchorNode);
|
|
27
|
-
range.setStart(anchorNode,
|
|
28
|
-
return range.comparePoint(focusNode,
|
|
26
|
+
range.setStart(anchorNode, anchorOffset);
|
|
27
|
+
return range.comparePoint(focusNode, focusOffset) < 0;
|
|
29
28
|
}
|
|
30
29
|
catch {
|
|
31
|
-
return
|
|
30
|
+
return !!(anchorNode.compareDocumentPosition(focusNode) & Node.DOCUMENT_POSITION_PRECEDING);
|
|
32
31
|
}
|
|
33
32
|
};
|
|
34
33
|
/** A text suggestion's preview at a DOM point: around it, or the child at either side of it. */
|
|
@@ -56,18 +55,6 @@ const atomBetween = (start, end, yStart, yEnd) => {
|
|
|
56
55
|
const part = from !== -1 && to === from + 2 ? content[from + 1] : null;
|
|
57
56
|
return part instanceof InlineBlock ? part : null;
|
|
58
57
|
};
|
|
59
|
-
/**
|
|
60
|
-
* Where a range ending in `block` starts (`first`) or ends (`last`): its
|
|
61
|
-
* first or last shown line (a list's items, a code block's lines); a block
|
|
62
|
-
* that shows none (a divider) passes to its neighbours as a caret does, the
|
|
63
|
-
* next line first.
|
|
64
|
-
*/
|
|
65
|
-
const rangeEdge = (block, edge) => {
|
|
66
|
-
const text = shownText(block, edge);
|
|
67
|
-
if (text)
|
|
68
|
-
return { text, offset: edge === 'first' ? 0 : text.length };
|
|
69
|
-
return caretBeside(block, 'blockAfter') ?? caretBeside(block, 'blockBefore');
|
|
70
|
-
};
|
|
71
58
|
/** One block's membership of the selected and focused sets, as its own two signals. */
|
|
72
59
|
class Membership {
|
|
73
60
|
selected = $state(false);
|
|
@@ -183,14 +170,18 @@ export class EdytorSelection {
|
|
|
183
170
|
texts
|
|
184
171
|
};
|
|
185
172
|
};
|
|
173
|
+
/** The DOM fields observed with the current value, if they were. */
|
|
174
|
+
get #observed() {
|
|
175
|
+
return this.#surface?.value === this.value ? this.#surface : undefined;
|
|
176
|
+
}
|
|
186
177
|
/** @internal The mark-edge side the DOM showed the current caret with (`marksForInsertion`). */
|
|
187
178
|
get edge() {
|
|
188
|
-
return this.#
|
|
179
|
+
return this.#observed?.edge;
|
|
189
180
|
}
|
|
190
181
|
/** @internal Focus is in a void block's own `input` or `textarea`. */
|
|
191
182
|
get voidField() {
|
|
192
|
-
|
|
193
|
-
|
|
183
|
+
return (this.projection.voidRoot !== null &&
|
|
184
|
+
/^(input|textarea)$/i.test(getActiveElement(this.edytor.node)?.tagName ?? ''));
|
|
194
185
|
}
|
|
195
186
|
/**
|
|
196
187
|
* Replace the selection with `next` (none, a text range of two anchors,
|
|
@@ -206,6 +197,7 @@ export class EdytorSelection {
|
|
|
206
197
|
* @internal
|
|
207
198
|
*/
|
|
208
199
|
commit = (next, cause = 'model', surface) => {
|
|
200
|
+
const { edytor } = this;
|
|
209
201
|
next = this.#keepPending(this.#shown(next));
|
|
210
202
|
const changed = !sameValue(this.value, next);
|
|
211
203
|
if (changed)
|
|
@@ -215,44 +207,39 @@ export class EdytorSelection {
|
|
|
215
207
|
// A repair is a background display (it never takes focus); a command,
|
|
216
208
|
// history or host code asks for one.
|
|
217
209
|
if (cause === 'repair')
|
|
218
|
-
|
|
210
|
+
edytor.surface.update();
|
|
219
211
|
else if (cause !== 'dom')
|
|
220
212
|
this.display();
|
|
221
213
|
if (surface)
|
|
222
214
|
this.#surface = { value, ...surface };
|
|
223
|
-
const projection = project(value,
|
|
224
|
-
const
|
|
215
|
+
const projection = project(value, edytor.facade);
|
|
216
|
+
const handles = edytor.idToBlock;
|
|
217
|
+
const blocksOf = (ids) => ids.flatMap((id) => handles.get(id) ?? []);
|
|
225
218
|
const atom = value.kind === 'atom' && projection.start
|
|
226
|
-
?
|
|
219
|
+
? handles.atom(value.blockId, value.atomId)
|
|
227
220
|
: undefined;
|
|
228
|
-
this.#sync(this.#selected, value.kind === 'blocks' ?
|
|
229
|
-
this.#sync(this.#focused, value.kind === 'text'
|
|
230
|
-
? blocksOf(projection.blocks)
|
|
231
|
-
: value.kind === 'atom'
|
|
232
|
-
? blocksOf([value.blockId])
|
|
233
|
-
: [], 'focused');
|
|
221
|
+
this.#sync(this.#selected, blocksOf(value.kind === 'blocks' ? value.ids : []), 'selected');
|
|
222
|
+
this.#sync(this.#focused, blocksOf(value.kind === 'text' ? projection.blocks : value.kind === 'atom' ? [value.blockId] : []), 'focused');
|
|
234
223
|
if (atom !== this.inlineBlockDeletionTarget || (!atom && this.selectedInlineBlock.size)) {
|
|
235
224
|
this.selectedInlineBlock.clear();
|
|
236
225
|
if (atom)
|
|
237
226
|
this.selectedInlineBlock.add(atom);
|
|
238
227
|
this.inlineBlockDeletionTarget = atom ?? null;
|
|
239
228
|
}
|
|
240
|
-
|
|
229
|
+
edytor.history?.selected(value);
|
|
241
230
|
const start = this.textAt(projection.start);
|
|
242
|
-
if (start)
|
|
231
|
+
if (start)
|
|
243
232
|
this.#lastText = start.text;
|
|
244
|
-
}
|
|
245
233
|
if (!changed)
|
|
246
234
|
return;
|
|
247
235
|
// A dead view never publishes: its entry went with its teardown.
|
|
248
|
-
if (!
|
|
236
|
+
if (!edytor.destroyed) {
|
|
249
237
|
const focus = projection.isReversed ? projection.start : projection.end;
|
|
250
|
-
|
|
238
|
+
edytor.presence.write(serialize(value, projection), focus?.block ?? null);
|
|
251
239
|
}
|
|
252
240
|
this.edytorOnSelectionChange?.(this);
|
|
253
|
-
|
|
241
|
+
for (const plugin of edytor.plugins)
|
|
254
242
|
plugin.onSelectionChange?.(this);
|
|
255
|
-
});
|
|
256
243
|
};
|
|
257
244
|
/**
|
|
258
245
|
* The collapsed text caret in block offsets (`{ block, offset }`, an
|
|
@@ -307,13 +294,17 @@ export class EdytorSelection {
|
|
|
307
294
|
return next;
|
|
308
295
|
const { start, end, isReversed } = project(next, this.edytor.facade);
|
|
309
296
|
const caret = next.focus === next.anchor;
|
|
297
|
+
// A range's end goes to its block's first or last shown line (a list's
|
|
298
|
+
// items, a code block's lines); a caret, or a block that shows none (a
|
|
299
|
+
// divider), passes to its neighbours, the next line first.
|
|
310
300
|
const moved = (at, anchor, edge) => {
|
|
311
301
|
const block = at && this.edytor.idToBlock.get(at.block);
|
|
312
302
|
if (!block || block.rendersContent)
|
|
313
303
|
return anchor;
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
304
|
+
const text = !caret && shownText(block, edge);
|
|
305
|
+
const to = text
|
|
306
|
+
? { text, offset: edge === 'first' ? 0 : text.length }
|
|
307
|
+
: (caretBeside(block, 'blockAfter') ?? caretBeside(block, 'blockBefore'));
|
|
317
308
|
return to && this.createTextAnchor(to.text, to.offset);
|
|
318
309
|
};
|
|
319
310
|
const [first, last] = isReversed ? ['last', 'first'] : ['first', 'last'];
|
|
@@ -477,8 +468,7 @@ export class EdytorSelection {
|
|
|
477
468
|
* `inlineBlockDeletionTarget` follow the value (`commit`).
|
|
478
469
|
*/
|
|
479
470
|
clearInlineBlockSelection = () => {
|
|
480
|
-
|
|
481
|
-
if (value.kind !== 'atom')
|
|
471
|
+
if (this.value.kind !== 'atom')
|
|
482
472
|
return;
|
|
483
473
|
const { start } = this.projection;
|
|
484
474
|
this.select(start ? this.valueAt(start) : noSelection);
|
|
@@ -533,10 +523,11 @@ export class EdytorSelection {
|
|
|
533
523
|
};
|
|
534
524
|
/** @internal */
|
|
535
525
|
applySelectionSnapshot = (selection, options = {}) => {
|
|
536
|
-
|
|
526
|
+
const { edytor, pointer } = this;
|
|
527
|
+
if (this.dragging && pointer.dragAcross(selection))
|
|
537
528
|
return;
|
|
538
529
|
// A block selection ignores the DOM, but the one a drag across columns made while it lasts.
|
|
539
|
-
if (this.selectedBlocks.size > 0 && !(this.dragging &&
|
|
530
|
+
if (this.selectedBlocks.size > 0 && !(this.dragging && pointer.across))
|
|
540
531
|
return;
|
|
541
532
|
// A selection in a kind's own control (a form control, a nested
|
|
542
533
|
// editable island) or a focused one belongs to it, not the model —
|
|
@@ -544,46 +535,49 @@ export class EdytorSelection {
|
|
|
544
535
|
// corrupt the next model-routed write. Both endpoints are checked: a
|
|
545
536
|
// range anchored in host text but focused inside an island must not
|
|
546
537
|
// resolve either.
|
|
547
|
-
const container =
|
|
538
|
+
const container = edytor.node;
|
|
548
539
|
const read = this.#read(selection);
|
|
549
|
-
if (!
|
|
540
|
+
if (!read || isKindControl(container, getActiveElement(container)))
|
|
550
541
|
return;
|
|
551
|
-
const { anchorNode, focusNode, isCollapsed } = selection;
|
|
542
|
+
const { anchorNode, focusNode, isCollapsed, rangeCount } = selection;
|
|
552
543
|
// A native vertical move that landed on no caret stop takes the key's line stop.
|
|
553
544
|
if (isCollapsed &&
|
|
554
545
|
!isTextBoundSelectionPoint(anchorNode) &&
|
|
555
|
-
!getInlineBlockOfNode(
|
|
556
|
-
landed(
|
|
546
|
+
!getInlineBlockOfNode(edytor, anchorNode) &&
|
|
547
|
+
landed(edytor))
|
|
557
548
|
return;
|
|
558
549
|
const { isReversed, startNode, start, endNode, end } = read;
|
|
559
|
-
const atom = getInlineBlockInSelectedRange(
|
|
560
|
-
const startText = atom ? null :
|
|
561
|
-
let endText = startText &&
|
|
550
|
+
const atom = getInlineBlockInSelectedRange(edytor, rangeCount ? selection.getRangeAt(0) : undefined);
|
|
551
|
+
const startText = atom ? null : read.startText;
|
|
552
|
+
let endText = startText && read.endText;
|
|
553
|
+
/** The atom selection: the DOM shows no range for it. */
|
|
554
|
+
const toAtom = (atom, from) => {
|
|
555
|
+
clearDomSelection(container);
|
|
556
|
+
this.selectInlineBlock(atom, from);
|
|
557
|
+
};
|
|
562
558
|
if (!startText) {
|
|
563
|
-
const inlineBlock = atom ?? getInlineBlockOfNode(
|
|
559
|
+
const inlineBlock = atom ?? getInlineBlockOfNode(edytor, startNode);
|
|
564
560
|
// A press in a non-native editable island (contenteditable=false, no control).
|
|
565
561
|
const island = getElementFromNode(startNode)?.closest('[contenteditable="false"]');
|
|
566
|
-
const
|
|
567
|
-
if (
|
|
568
|
-
return this.selectText(
|
|
562
|
+
const first = island && !isNativeInteractiveControl(island) && this.getBlockOfNode(island)?.firstText;
|
|
563
|
+
if (first)
|
|
564
|
+
return this.selectText(first, 0);
|
|
569
565
|
// A point on a white space node: the model caret is displayed again.
|
|
570
566
|
if (!inlineBlock && this.textAt(this.projection.start))
|
|
571
567
|
return this.display();
|
|
572
|
-
clearDomSelection(this.edytor.node);
|
|
573
568
|
if (inlineBlock)
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return;
|
|
569
|
+
return toAtom(inlineBlock);
|
|
570
|
+
clearDomSelection(container);
|
|
571
|
+
return this.clearInlineBlockSelection();
|
|
578
572
|
}
|
|
579
|
-
const overlay = isCollapsed
|
|
573
|
+
const overlay = isCollapsed && overlayAt(startNode, start);
|
|
580
574
|
if (overlay) {
|
|
581
575
|
const text = this.getBlockOfNode(overlay)?.lastText ?? startText;
|
|
582
576
|
return this.selectText(text, text.length);
|
|
583
577
|
}
|
|
584
578
|
const rawYStart = getYIndex(startText, startNode, start);
|
|
585
579
|
const rawYEnd = isCollapsed ? rawYStart : getYIndex(endText, endNode, end);
|
|
586
|
-
|
|
580
|
+
const yStart = normalizeUtf16Boundary(startText.stringContent, rawYStart, isCollapsed ? 'forward' : 'backward');
|
|
587
581
|
let yEnd = isCollapsed
|
|
588
582
|
? yStart
|
|
589
583
|
: endText
|
|
@@ -599,7 +593,7 @@ export class EdytorSelection {
|
|
|
599
593
|
endText === endText.parent.firstText &&
|
|
600
594
|
yEnd === 0) {
|
|
601
595
|
// The shown block before: a closed toggle's header, not its hidden body.
|
|
602
|
-
const previous = visibility.shown(endText.parent, 'blockBefore')?.lastText
|
|
596
|
+
const previous = visibility.shown(endText.parent, 'blockBefore')?.lastText;
|
|
603
597
|
if (previous?.node?.isConnected) {
|
|
604
598
|
endText = previous;
|
|
605
599
|
yEnd = previous.length;
|
|
@@ -611,11 +605,8 @@ export class EdytorSelection {
|
|
|
611
605
|
return this.selectText(startText, startText.length);
|
|
612
606
|
}
|
|
613
607
|
const between = atomBetween(startText, endText, yStart, yEnd);
|
|
614
|
-
if (between)
|
|
615
|
-
|
|
616
|
-
this.selectInlineBlock(between, isReversed ? 'after' : 'before');
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
608
|
+
if (between)
|
|
609
|
+
return toAtom(between, isReversed ? 'after' : 'before');
|
|
619
610
|
const value = this.textValue(startText, yStart, endText ?? startText, yEnd, isReversed);
|
|
620
611
|
// A node-bound native range (endpoints outside text elements: a block
|
|
621
612
|
// node selection, a stray boundary node) is the shape engines extend
|
|
@@ -629,11 +620,11 @@ export class EdytorSelection {
|
|
|
629
620
|
edge: isCollapsed ? getMarkEdgeSide(startText, startNode, yStart) : undefined,
|
|
630
621
|
nodeBound
|
|
631
622
|
});
|
|
632
|
-
|
|
623
|
+
edytor.projector.observe();
|
|
633
624
|
if (restore && options.restoreNormalizedDomRange !== false && endText) {
|
|
634
625
|
// Writing the DOM under a pointer drag would reset its anchor: after it.
|
|
635
626
|
if (this.dragging)
|
|
636
|
-
|
|
627
|
+
pointer.hold();
|
|
637
628
|
else
|
|
638
629
|
this.selectText(startText, yStart, endText, yEnd, isReversed);
|
|
639
630
|
}
|
|
@@ -644,7 +635,7 @@ export class EdytorSelection {
|
|
|
644
635
|
* engine-defined and therefore intercepted by the hotkey layer.
|
|
645
636
|
* @internal
|
|
646
637
|
*/
|
|
647
|
-
hasNativeNodeSelection = () => this.#
|
|
638
|
+
hasNativeNodeSelection = () => this.#observed?.nodeBound === true;
|
|
648
639
|
/**
|
|
649
640
|
* Anchor a text position into the BACKING text that owns the atom at
|
|
650
641
|
* that display position — the v14 replacement for
|
|
@@ -717,16 +708,13 @@ export class EdytorSelection {
|
|
|
717
708
|
* @internal
|
|
718
709
|
*/
|
|
719
710
|
restoreDeadSelectionEndpoints = () => {
|
|
720
|
-
|
|
711
|
+
// A history step selects its own value; mid pointer-drag the user's
|
|
712
|
+
// in-progress range owns the selection; a command that declared its
|
|
713
|
+
// result selection owns this view's endpoints.
|
|
714
|
+
if (this.expectHistoryRestore || this.dragging || this.edytor.dispatcher.authoring)
|
|
721
715
|
return;
|
|
722
|
-
}
|
|
723
|
-
// Mid pointer-drag the user's in-progress range owns the selection; a
|
|
724
|
-
// command that declared its result selection owns this view's endpoints.
|
|
725
|
-
if (this.dragging || this.edytor.dispatcher.authoring) {
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
716
|
const { value } = this;
|
|
729
|
-
const facade = this.edytor
|
|
717
|
+
const { facade } = this.edytor;
|
|
730
718
|
const { start, isCollapsed } = this.projection;
|
|
731
719
|
const at = this.textAt(start);
|
|
732
720
|
let dead;
|
|
@@ -734,10 +722,8 @@ export class EdytorSelection {
|
|
|
734
722
|
const live = value.ids.filter((id) => facade.isVisibleBlock(id));
|
|
735
723
|
if (live.length === value.ids.length)
|
|
736
724
|
return;
|
|
737
|
-
if (live.length)
|
|
738
|
-
this
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
725
|
+
if (live.length)
|
|
726
|
+
return this.#land(blockSelection(live));
|
|
741
727
|
dead = value.ids[0] ?? null;
|
|
742
728
|
}
|
|
743
729
|
else if (value.kind === 'atom') {
|
|
@@ -746,32 +732,26 @@ export class EdytorSelection {
|
|
|
746
732
|
const anchor = facade.isVisibleBlock(value.blockId)
|
|
747
733
|
? facade.anchorAt(value.blockId, 0, 'left')
|
|
748
734
|
: null;
|
|
749
|
-
if (anchor)
|
|
750
|
-
this.#land(textSelection(anchor));
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
735
|
+
if (anchor)
|
|
736
|
+
return this.#land(textSelection(anchor));
|
|
753
737
|
dead = value.blockId;
|
|
754
738
|
}
|
|
755
739
|
else if (value.kind === 'text') {
|
|
756
740
|
// A peer or host code turned the block it rests in into one that shows
|
|
757
741
|
// no content (an empty line into a divider): the nearest shown line.
|
|
758
742
|
const shown = this.#shown(value);
|
|
759
|
-
if (shown !== value)
|
|
760
|
-
this.#land(shown);
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
if (at && start && isCollapsed && value.anchor !== value.focus) {
|
|
743
|
+
if (shown !== value)
|
|
744
|
+
return this.#land(shown);
|
|
745
|
+
if (at && isCollapsed && value.anchor !== value.focus) {
|
|
764
746
|
// A range whose content died is a caret at the seam (`sel.seam.covered-atom`):
|
|
765
747
|
// its two anchors would re-open around text re-inserted there.
|
|
766
748
|
const caret = facade.anchorAt(start.block, start.offset, 'left');
|
|
767
|
-
if (caret)
|
|
768
|
-
this.#land(textSelection(caret));
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
749
|
+
if (caret)
|
|
750
|
+
return this.#land(textSelection(caret));
|
|
771
751
|
}
|
|
772
752
|
if (at) {
|
|
773
753
|
// The text it was displayed in died while its anchor moved on: display again.
|
|
774
|
-
if (this.#lastText
|
|
754
|
+
if (this.#lastText && !this.#lastText.isInDocument)
|
|
775
755
|
this.display();
|
|
776
756
|
this.#lastText = at.text;
|
|
777
757
|
return;
|
|
@@ -804,25 +784,26 @@ export class EdytorSelection {
|
|
|
804
784
|
*/
|
|
805
785
|
mint = (snapshot) => {
|
|
806
786
|
const read = this.#read(snapshot);
|
|
807
|
-
|
|
808
|
-
return null;
|
|
809
|
-
const { isReversed, startNode, start, endNode, end } = read;
|
|
810
|
-
const startText = this.getTextOfNode(startNode, start);
|
|
811
|
-
const endText = startText && this.getTextOfNode(endNode, end);
|
|
787
|
+
const { startText, endText } = read ?? {};
|
|
812
788
|
if (!startText || !endText)
|
|
813
789
|
return null;
|
|
790
|
+
const { isReversed, startNode, start, endNode, end } = read;
|
|
814
791
|
const from = offsetIn(startText, startNode, start);
|
|
815
792
|
return this.textValue(startText, from, endText, offsetIn(endText, endNode, end), isReversed);
|
|
816
793
|
};
|
|
817
794
|
/**
|
|
818
795
|
* A DOM selection whose two endpoints are this editor's own points (not
|
|
819
|
-
* in a kind's control): its endpoints in document order
|
|
796
|
+
* in a kind's control): its endpoints in document order, its direction,
|
|
797
|
+
* and the texts they are in (the end's only when the start is in one).
|
|
820
798
|
*/
|
|
821
799
|
#read = (dom) => {
|
|
822
800
|
const container = this.edytor.node;
|
|
823
801
|
if (!dom || !isOwnPoint(container, dom.anchorNode) || !isOwnPoint(container, dom.focusNode))
|
|
824
802
|
return null;
|
|
825
803
|
const isReversed = isBackward(dom);
|
|
826
|
-
|
|
804
|
+
const ends = ordered(dom, isReversed);
|
|
805
|
+
const startText = this.getTextOfNode(ends.startNode, ends.start);
|
|
806
|
+
const endText = startText && this.getTextOfNode(ends.endNode, ends.end);
|
|
807
|
+
return { isReversed, ...ends, startText, endText };
|
|
827
808
|
};
|
|
828
809
|
}
|
package/dist/session/commands.js
CHANGED
|
@@ -6,6 +6,7 @@ import { DEV } from 'esm-env';
|
|
|
6
6
|
import { PreventionError, vetoable } from '../utils.js';
|
|
7
7
|
import { kindOf } from './attempt.js';
|
|
8
8
|
import { insertionMarksAt } from './editing/text.js';
|
|
9
|
+
import { diffText } from '../utils/diffText.js';
|
|
9
10
|
/** A command the view refused (readonly, a read-only document) or a hook prevented. */
|
|
10
11
|
const PREVENTED = refused('view');
|
|
11
12
|
/**
|
|
@@ -73,26 +74,17 @@ const textPatch = (args) => {
|
|
|
73
74
|
};
|
|
74
75
|
};
|
|
75
76
|
/**
|
|
76
|
-
* `
|
|
77
|
-
*
|
|
78
|
-
*
|
|
77
|
+
* `patch` continues `previous`: the same paths, each string edited by typing
|
|
78
|
+
* (one contiguous run typed in or deleted, what typing and Backspace produce;
|
|
79
|
+
* another value, 'todo' → 'done', is not).
|
|
79
80
|
*/
|
|
80
|
-
const typedFrom = (previous, next) => {
|
|
81
|
-
const shorter = Math.min(previous.length, next.length);
|
|
82
|
-
let prefix = 0;
|
|
83
|
-
while (prefix < shorter && previous[prefix] === next[prefix])
|
|
84
|
-
prefix++;
|
|
85
|
-
let suffix = 0;
|
|
86
|
-
while (suffix < shorter - prefix &&
|
|
87
|
-
previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix])
|
|
88
|
-
suffix++;
|
|
89
|
-
return prefix + suffix === shorter;
|
|
90
|
-
};
|
|
91
|
-
/** `patch` continues `previous`: the same paths, each string edited by typing. */
|
|
92
81
|
const continuesTyping = (previous, patch) => patch !== null &&
|
|
93
82
|
previous !== null &&
|
|
94
83
|
patch.key === previous.key &&
|
|
95
|
-
patch.values.every((value, i) =>
|
|
84
|
+
patch.values.every((value, i) => {
|
|
85
|
+
const edit = diffText(previous.values[i], value);
|
|
86
|
+
return !edit || !edit.remove || !edit.insert;
|
|
87
|
+
});
|
|
96
88
|
/** Passes one normalizer may take on one block per command: the first and 50 re-requests. */
|
|
97
89
|
const MAX_PASSES = 51;
|
|
98
90
|
/**
|
|
@@ -221,9 +213,10 @@ export class Dispatcher {
|
|
|
221
213
|
* writable, and decided when it requires hydration. Every command asks
|
|
222
214
|
* it first; a plugin's own write outside a command asks it too.
|
|
223
215
|
*/
|
|
224
|
-
permits = () =>
|
|
225
|
-
this.edytor
|
|
226
|
-
(
|
|
216
|
+
permits = () => {
|
|
217
|
+
const { readonly, document } = this.edytor;
|
|
218
|
+
return !readonly && document.writable && (document.ready || !document.requireHydration);
|
|
219
|
+
};
|
|
227
220
|
/**
|
|
228
221
|
* Apply the undo policy's cut before a `kind` command writes.
|
|
229
222
|
* @internal
|
|
@@ -292,7 +285,7 @@ export class Dispatcher {
|
|
|
292
285
|
this.kind = null;
|
|
293
286
|
}
|
|
294
287
|
});
|
|
295
|
-
if (this.applied && this
|
|
288
|
+
if (this.applied && this.#last?.status !== 'applied' && this.#last?.status !== 'failed')
|
|
296
289
|
this.#last = this.applied;
|
|
297
290
|
this.applied = null;
|
|
298
291
|
const after = () => this.cut(kind, 'after');
|
|
@@ -317,15 +310,15 @@ export class Dispatcher {
|
|
|
317
310
|
dispatch = (operation, payload, context, body, prepare) => {
|
|
318
311
|
if (this.active)
|
|
319
312
|
return body(payload);
|
|
313
|
+
const { edytor } = this;
|
|
314
|
+
const { facade, plugins } = edytor;
|
|
320
315
|
const lead = this.leading;
|
|
321
316
|
this.leading = null;
|
|
322
317
|
// A led operation, or one `alone` runs: its own step, never an insertion's.
|
|
323
318
|
const led = !!lead || this.#alone;
|
|
324
319
|
this.#alone = false;
|
|
325
|
-
if (!this.permits())
|
|
326
|
-
this.#refused(operation);
|
|
327
|
-
return undefined;
|
|
328
|
-
}
|
|
320
|
+
if (!this.permits())
|
|
321
|
+
return void this.#refused(operation);
|
|
329
322
|
const original = payload;
|
|
330
323
|
const replaced = new Set();
|
|
331
324
|
// A lead composes with the command's plan (both prepared at this version);
|
|
@@ -334,21 +327,22 @@ export class Dispatcher {
|
|
|
334
327
|
const own = prepare?.(p);
|
|
335
328
|
if (!lead || !own || !('writes' in own))
|
|
336
329
|
return own;
|
|
337
|
-
const { displayLength, compose } =
|
|
330
|
+
const { displayLength, compose } = facade;
|
|
338
331
|
const whole = own.writes.flatMap((w) => w.op === 'deleteText' && w.offset === 0 && w.length === displayLength(w.id) ? [w.id] : []);
|
|
339
332
|
const writes = lead.writes.filter((w) => !('id' in w && w.id && whole.includes(w.id)));
|
|
340
333
|
return compose({ ...lead, writes }, own);
|
|
341
334
|
};
|
|
342
335
|
let plan = prepared(payload);
|
|
336
|
+
let planned;
|
|
343
337
|
hooks: for (;;) {
|
|
344
338
|
// A command refused at preparation is still shown (without steps): an
|
|
345
339
|
// extension may replace or retarget it.
|
|
346
|
-
|
|
340
|
+
planned = plan && 'writes' in plan ? plan : undefined;
|
|
347
341
|
const command = { operation, payload, ...context, effect: planned?.effect };
|
|
348
342
|
const steps = [...(lead && !plan ? lead.writes : []), ...(planned?.writes ?? [])];
|
|
349
343
|
const changes = [command, ...this.steps(steps, planned?.effect.creates ?? [], command)];
|
|
350
344
|
for (const change of changes) {
|
|
351
|
-
for (const plugin of
|
|
345
|
+
for (const plugin of plugins) {
|
|
352
346
|
let out;
|
|
353
347
|
try {
|
|
354
348
|
out = vetoable((prevent) => plugin.onBeforeOperation?.({ ...change, prevent }));
|
|
@@ -384,33 +378,33 @@ export class Dispatcher {
|
|
|
384
378
|
break;
|
|
385
379
|
}
|
|
386
380
|
// Refused at preparation, and no extension replaced it.
|
|
387
|
-
if (plan && !
|
|
381
|
+
if (plan && !planned) {
|
|
388
382
|
this.#refused(operation);
|
|
389
383
|
return body(payload, plan);
|
|
390
384
|
}
|
|
391
|
-
const version =
|
|
385
|
+
const version = facade.version;
|
|
392
386
|
// A text patch typing into the strings the previous command set on the
|
|
393
387
|
// same paths (a bound field) continues its step, within the capture
|
|
394
388
|
// window, as typing does; another value of them is its own step.
|
|
395
389
|
const patch = operation === 'patchData' ? textPatch(payload) : null;
|
|
396
|
-
const again = continuesTyping(this.patched, patch) && this
|
|
390
|
+
const again = continuesTyping(this.patched, patch) && this.#last?.operation === operation;
|
|
397
391
|
// A write outside the history continues nothing and leaves the typing's record.
|
|
398
392
|
if (!this.untracked)
|
|
399
393
|
this.patched = patch;
|
|
400
394
|
const cut = again ? undefined : this.policy(operation, led);
|
|
401
395
|
if (cut)
|
|
402
|
-
|
|
396
|
+
edytor.undoManager?.stopCapturing();
|
|
403
397
|
let result;
|
|
404
398
|
try {
|
|
405
|
-
result =
|
|
399
|
+
result = edytor.transact(() => {
|
|
406
400
|
// What the step holds, recorded as the transaction ends: only an
|
|
407
401
|
// insertion's step may be continued (`history.continues`).
|
|
408
|
-
|
|
402
|
+
edytor.history.wrote(inserts(this.kind ?? operation, led));
|
|
409
403
|
this.active = true;
|
|
410
404
|
try {
|
|
411
405
|
// An unplanned command runs after its lead, reading the state it leaves.
|
|
412
406
|
if (lead && !plan)
|
|
413
|
-
void (this.writer?.(lead) ??
|
|
407
|
+
void (this.writer?.(lead) ?? facade.apply(lead));
|
|
414
408
|
return body(payload, plan);
|
|
415
409
|
}
|
|
416
410
|
finally {
|
|
@@ -423,17 +417,21 @@ export class Dispatcher {
|
|
|
423
417
|
throw error;
|
|
424
418
|
}
|
|
425
419
|
if (cut === 'both')
|
|
426
|
-
|
|
420
|
+
edytor.undoManager?.stopCapturing();
|
|
427
421
|
const own = {
|
|
428
422
|
operation,
|
|
429
|
-
status:
|
|
423
|
+
status: facade.version === version ? 'noop' : 'applied'
|
|
430
424
|
};
|
|
431
425
|
this.#last = own;
|
|
432
426
|
if (this.running && own.status === 'applied')
|
|
433
427
|
this.applied = own;
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
428
|
+
const change = {
|
|
429
|
+
operation,
|
|
430
|
+
payload: original,
|
|
431
|
+
...context,
|
|
432
|
+
effect: planned?.effect
|
|
433
|
+
};
|
|
434
|
+
for (const plugin of plugins)
|
|
437
435
|
plugin.onAfterOperation?.(change);
|
|
438
436
|
return result;
|
|
439
437
|
};
|
|
@@ -452,10 +450,11 @@ export class Dispatcher {
|
|
|
452
450
|
const out = this.dispatch(operation, payload, { block: block ?? this.edytor.root, ...(text && { text }) }, (p, plan = prepare(p)) => {
|
|
453
451
|
const out = this.write(plan);
|
|
454
452
|
// Read once written: a plan a plugin wrote says where its caret stands now.
|
|
455
|
-
at =
|
|
453
|
+
at = plan.at;
|
|
456
454
|
return out;
|
|
457
455
|
}, prepare);
|
|
458
|
-
|
|
456
|
+
// A caret is set only by a body that wrote (`out` is its result).
|
|
457
|
+
if (at && !this.active && out.status !== 'refused')
|
|
459
458
|
this.caret(at);
|
|
460
459
|
return out ?? PREVENTED;
|
|
461
460
|
};
|
|
@@ -524,22 +523,22 @@ export class Dispatcher {
|
|
|
524
523
|
#touched(w) {
|
|
525
524
|
const { facade } = this.edytor;
|
|
526
525
|
const parent = (id) => facade.parentOf(id);
|
|
526
|
+
const content = (...ids) => ids.map((id) => [id, 'normalizeContent']);
|
|
527
527
|
const children = (...ids) => ids.map((id) => [id ?? 'root', 'normalizeChildren']);
|
|
528
528
|
if (w.op === 'insertBlocks')
|
|
529
|
-
return [...children(w.parent), ...w.specs.map(({ id }) =>
|
|
529
|
+
return [...children(w.parent), ...content(...w.specs.map(({ id }) => id))];
|
|
530
530
|
if (w.op === 'moveBlocks')
|
|
531
531
|
return children(w.parent, ...w.ids.map(parent));
|
|
532
532
|
if (w.op === 'deleteBlock')
|
|
533
533
|
return children(parent(w.id));
|
|
534
534
|
if (w.op === 'splitBlock')
|
|
535
|
-
return [
|
|
535
|
+
return [...content(w.id), ...children(w.parent, parent(w.id))];
|
|
536
536
|
if (w.op === 'mergeBlocks')
|
|
537
|
-
return [
|
|
537
|
+
return [...content(w.into), ...children(parent(w.from))];
|
|
538
538
|
if (w.op === 'setBlockType')
|
|
539
|
-
return [
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
return [[w.id, 'normalizeContent']];
|
|
539
|
+
return [...content(w.id), ...children(w.id)];
|
|
540
|
+
// A text step's block; a data patch's, none for the document's data.
|
|
541
|
+
return w.id === undefined ? [] : content(w.id);
|
|
543
542
|
}
|
|
544
543
|
/**
|
|
545
544
|
* Run `body` with `lead` (a plan prepared now) composed into the first
|
|
@@ -654,9 +653,8 @@ export class Dispatcher {
|
|
|
654
653
|
const block = edytor.idToBlock.get(id);
|
|
655
654
|
if (!block)
|
|
656
655
|
continue;
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
counts.set(id, count);
|
|
656
|
+
const count = (passes.get(normalize + id) ?? 0) + 1;
|
|
657
|
+
passes.set(normalize + id, count);
|
|
660
658
|
if (count > MAX_PASSES) {
|
|
661
659
|
if (DEV && count === MAX_PASSES + 1)
|
|
662
660
|
console.warn(`edytor: ${normalize} on block "${id}" exceeded ${MAX_PASSES - 1} passes — a plugin normalizer is not converging; skipping further passes`);
|
|
@@ -32,12 +32,12 @@ export type Kind = 'block' | 'text' | 'mark' | 'atom' | 'anchor' | 'preview';
|
|
|
32
32
|
* moves the caret by its paragraph's direction, never an inline run's.
|
|
33
33
|
*/
|
|
34
34
|
export declare const blockDir: (text: string) => "rtl" | "ltr" | null;
|
|
35
|
-
/** The text a cell's runs show
|
|
36
|
-
export declare const ownText: (runs: readonly ContentRun[]) => string;
|
|
35
|
+
/** The text a cell's runs show, each atom as `atom` (left out: what `blockDir` reads). */
|
|
36
|
+
export declare const ownText: (runs: readonly ContentRun[], atom?: string) => string;
|
|
37
37
|
/** A text inside a void block is its own editing host (the core sets it at attach). */
|
|
38
38
|
export declare const insideVoid: (edytor: Edytor, block: string) => boolean;
|
|
39
39
|
/** What the table owns on `element` (the root, or a registered element). */
|
|
40
|
-
export declare const ownedOf: (edytor: Edytor, element: Element, entry
|
|
40
|
+
export declare const ownedOf: (edytor: Edytor, element: Element, entry?: {
|
|
41
41
|
kind: Kind;
|
|
42
42
|
block: string;
|
|
43
43
|
name?: string;
|