Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lexical-y-set-text-caret-past-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lexical/y': patch
---

[fix] Place an insertion or a deletion of text set with the caret at the end of the node, past the edit or in a later node at a word edge, as without a caret

Verify: `node.setTextContent('t1z t3z'); node.selectEnd();` on a node holding `t1z t2z t3z`, while a collaborator deletes `t2z`, shows `t1z t3z` on every client (not `t1z 3z`), and a word the collaborator types after `t2z` stays a word of its own.
6 changes: 4 additions & 2 deletions packages/lexical-website/docs/collaboration/y.md
Original file line number Diff line number Diff line change
Expand Up @@ -834,8 +834,10 @@ person's:
(and a change the browser makes to the DOM text, as an IME composition
does) keeps the start and end its text shares with the node's old text,
anchored at the caret, which cannot tell where an edit next to equal text
was made without one. Without a caret, an insertion or a deletion found
that way stays where the diff puts it if both its ends are at word edges;
was made without one. Without a caret that places it (none, or one at
the end of the node, past the edit or in a later node), an insertion or
Comment on lines +837 to +838

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit: a caret at the end of the node still places an edit that could end there. setTextEnds('ab ab', 'ab abz ab', 9, false) keeps z ab at the end (the cell at SetTextWordEdges.test.ts:229), where no caret writes abz at 2. Something like "(none, or one past the furthest place the edit could be: at the end of the node or in a later node, unless the edit could end there)" would match caretPlaces; the D30 row and the describe comment at SetTextWordEdges.test.ts:214-218 use the same list.

a deletion found that way stays where the diff puts it if both its ends
are at word edges;
where that is inside a word, it goes to the first place where both are,
so it changes no word it does not add or remove (`t1z t2z t3z` set to
`t1z t3z` deletes ` t2z`, not `2z t`, and a collaborator's concurrent
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-y/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ issues and docs that carry it.
| D27 | **Moves are a delete and a re-insert** at launch; the binding rewrites the cheaper side. Real moves within a container ([#9449], records after Yjs's unmerged `ContentMove` design) are paused and land last, backward compatible: a new layout version that older clients open read only. Move records leave tombstones, as every Yjs binding does. Moves across containers stay copies. | Every Yjs binding turns moves into delete plus insert; Yjs dropped its own move for complexity. Reliability of the rest came first. | `DECIDED` | 10-25 | [#9449], [#9455], [y.md: Limits][limits] |
| D28 | **Text identity is fixed at the source.** While a binding is attached, core records text edits (`spliceText`, insert and remove, split and merge), and text entity plugins record a grow as a move and a remake as a retype. Text only `setTextContent` or the DOM changed is diffed. The binding adds no per-case heuristics. | No binding can recover intent an API dropped; special-case rules were fragile and regressed neighbouring cases. | `DECIDED` | 10-27 | [#9483], [y.md: Edits made by code][y-code] |
| D29 | **Remade text keeps its common start and end by character**, whatever the node boundaries (y-prosemirror's `updateYText` rule), **and between two changes every run of unchanged whole words** (the longest common run of words, by a linear-space shortest edit script over the words both texts have, up to 2,048 edits, words only added or only removed between a shared start and end taking none, linear in the line's length; past it, only the start and end; where a word repeats next to a change, which occurrence is kept follows that edit script (`aa bb` made again as `aa aa bb` keeps the first `aa`); a word the update moved is written again; a word changed only in part is written whole, so a collaborator's undone delete of its unchanged part shows twice, [#9635]), and never brings back a collaborator's delete. It replaces the word matching of [#9565]. | It is the industry-standard behaviour, and word matching glued words together and merged numbers oddly. Keeping only the start and end wrote everything between two changed words again, so a collaborator's delete there came back, and showed twice after the collaborator's undo ([#9621]). | `DECIDED` | 10-30 | [#9565], [#9598], [#9621] |
| D30 | **Diffed rewrites snap to whole words**; keystrokes and IME composition keep the exact diff. Scripts without spaces treat each character as a boundary. **A diffed insertion or deletion without a caret that the diff would put inside a word goes to the first place in the run of equal text it could be in where both its ends are at word edges** (a word deleted with the separator before it); a caret's placement, and one already at word edges, stay. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). Without a caret a diff takes the common start first and put deletions inside words, so a collaborator's concurrent delete of the same word took a letter of the next one ([#9636]); the first edge keeps a word typed after a deleted word apart from the next one, as typing goes (a space, then a word). | `DECIDED` | 10-30 | [#9400], [#9467], [#9636] |
| D30 | **Diffed rewrites snap to whole words**; keystrokes and IME composition keep the exact diff. Scripts without spaces treat each character as a boundary. **A diffed insertion or deletion that no caret places (none, or one at the end of the node, past the edit or in a later node) and that the diff would put inside a word goes to the first place in the run of equal text it could be in where both its ends are at word edges** (a word deleted with the separator before it); a caret's placement, and one already at word edges, stay. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). Without a caret a diff takes the common start first and put deletions inside words, so a collaborator's concurrent delete of the same word took a letter of the next one ([#9636]); the first edge keeps a word typed after a deleted word apart from the next one, as typing goes (a space, then a word). | `DECIDED` | 10-30 | [#9400], [#9467], [#9636] |
| D31 | **A list item's nested list gets an item of its own.** `NodeStructureExtension`'s list rule prevents an item holding a nested list next to other content when it is written; the binding does not repair it afterwards. | That shape lost text in outdents, Backspaces and offline merges. Preventing it at write time follows D20. | `DECIDED` | 10-26 | [#9452], [y.md: Limits][limits] |
| D32 | **List merging ignores `start`.** | Existing Lexical behaviour, intended. | `DECIDED` | 10-23 | [#9452] |
| D70 | **No virtual structure.** The editor does not show repairs that are not stored (padded cells for a short table row, two stored lists drawn as one). The one existing case stays: text left without a line marker shows as a paragraph. | It adds another structure on top of Yjs and the Lexical tree, and the complexity that comes with it. | `DECIDED` | 10-25 | [#9400] |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,106 @@ describe('#9636: a caret keeps placing the edit', () => {
}
});

// A caret at the end of the node, or anywhere past the edit, anchors every
// place of the run of equal text, so it places nothing: `setTextContent`
// followed by `selectEnd()` (what code that sets a node's text usually does)
// is placed as without a caret, and so is a set with the caret in a later
// node, for which the writer anchors the diff at the end of the node.
describe('#9636: a caret past the edit', () => {
test('places nothing: `t1z t2z t3z` set to `t1z t3z` with the caret at the end', () => {
expect(setTextEnds('t1z t2z t3z', 't1z t3z', 7, false)).toEqual({
prefix: 3,
suffix: 4,
});
});
// A caret in the run, the furthest place included, still places it.
for (const [a, b, cursor, want] of [
['t1z t2z t3z', 't1z t3z', 5, {prefix: 5, suffix: 2}],
['ab ab', 'ab abz ab', 9, {prefix: 5, suffix: 0}],
] as const) {
test(`a caret in the run places it: ${JSON.stringify(a)} set to ${JSON.stringify(b)} with the caret at ${cursor}`, () => {
expect(setTextEnds(a, b, cursor, false)).toEqual(want);
});
}
for (const ids of IDS) {
for (const record of [true, false]) {
const how = record ? 'recorded' : 'diffed by the writer';
// Typed right before `t2z`, the peer's word is glued to the word
// before it, as y.md documents for a set without a caret (it was
// `t1z q9z t3z` while a caret at the end pinned the diff).
for (const [peer, splice, want] of [
['deletes t2z', [4, 3, ''], 't1z t3z'],
['types " q9z" after t2z', [7, 0, ' q9z'], 't1z q9z t3z'],
['types "q9z " before t2z', [4, 0, 'q9z '], 't1zq9z t3z'],
] as const) {
for (const caret of ['at the end', 'in the next node'] as const) {
test(`set with the caret ${caret} ‖ the peer ${peer}, ${how} (ids ${ids})`, async () => {
const t = await setup(
() => {
const line = $createParagraphNode().append(
$createTextNode('t1z t2z t3z'),
);
if (caret === 'in the next node') {
line.append($createTextNode('!').toggleFormat('bold'));
}
$getRoot().append(line);
},
{ids},
);
const recording = t.a.editor._recordTextEdits;
try {
t.a.editor._recordTextEdits = record;
t.offline();
t.a.update(() => {
$node().setTextContent('t1z t3z');
if (caret === 'at the end') {
$node().selectEnd();
} else {
($node().getNextSibling() as TextNode).select(1, 1);
}
});
t.b.update($splice(splice[0], splice[1], splice[2]));
await t.sync();
const end = caret === 'in the next node' ? '!' : '';
const fresh = await freshReader(t.a);
expect(
[...t.peers, fresh].map(p =>
p.read(() => $getRoot().getTextContent()),
),
).toEqual([want + end, want + end, want + end]);
await expectConverged(t.peers);
} finally {
t.a.editor._recordTextEdits = recording;
}
});
}
}
}
test(`\`1..\` set to \`1.\` with the caret at 2 ‖ the peer types between the dots (ids ${ids})`, async () => {
const t = await setup(
() => {
$getRoot().append(
$createParagraphNode().append($createTextNode('1..')),
);
},
{ids},
);
t.a.editor.update(() => $node().select(3, 3), {discrete: true});
t.offline();
t.a.update(() => {
$node().setTextContent('1.');
$node().select(2, 2);
});
t.b.update($splice(2, 0, 'x'));
await t.sync();
expect(
t.peers.map(p => p.read(() => $getRoot().getTextContent())),
).toEqual(['1.x', '1.x']);
await expectConverged(t.peers);
});
}
});

// A word set away next to a word that ends or starts like it: the edit is
// not moved onto a piece of a word that stays (`a ba c` set to `a c`
// deletes ` ba`, not `a b`, which would keep the `a` of `ba` as the first
Expand Down Expand Up @@ -350,19 +450,15 @@ describe('#9636: set text and a collaborator edit keep every word whole', () =>
if (new Set(texts).size > 1) {
problems.push(`diverged ${JSON.stringify(texts)}`);
}
// A word typed against the deleted word, glued to its neighbour.
const gone = insertSet ? null : words[target];
// A word typed against the deleted word, glued to its neighbour
// on the side y.md documents: typed before it, to the word before
// it; typed after a line's first word, to the word after it.
const gone = insertSet || victim !== words[target] ? -1 : target;
const glued = (w: string) =>
gone !== null &&
(victim === gone ||
words[words.indexOf(gone) - 1] === victim ||
words[words.indexOf(gone) + 1] === victim) &&
(w.startsWith(typed) || w.endsWith(typed)) &&
next.includes(
w.startsWith(typed)
? w.slice(typed.length)
: w.slice(0, -typed.length),
);
gone >= 0 &&
(edit === 'type before'
? gone > 0 && w === words[gone - 1] + typed
: edit === 'type after' && gone === 0 && w === typed + words[1]);
for (const w of got.filter(glued)) {
got.splice(
got.indexOf(w),
Expand Down
19 changes: 17 additions & 2 deletions packages/lexical-y/src/internal/Inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,12 +767,27 @@ function placeAtWordEdge(
return null;
}

/**
* Whether the caret at `cursor` of `b` placed the insertion or deletion that
* makes `b` of `a`: it anchors the diff (caretAnchors) and its bound is in
* the run of equal text the edit could be in, the furthest place included.
* A caret past the furthest place (at the end of the node or past the edit,
* and the end of the node, which the writer passes for a caret in a later
* node) anchors every place of the run and places nothing.
*/
function caretPlaces(a: string, b: string, cursor: number): boolean {
return (
caretAnchors(a, b, cursor) &&
cursor - Math.max(0, b.length - a.length) <= diffStrings(a, b, -1).prefix
);
}

/**
* The common start and end that text set whole (`setTextContent`, a node's
* text made again, the DOM's text) keeps of the text it replaces, anchored
* at `cursor` (see diffStrings): a replacement keeps whole words
* (snapToWords); an insertion or a deletion keeps every other character,
* where the caret placed it (diffStrings), or where the diff put it if both
* where the caret placed it (caretPlaces), or where the diff put it if both
* its ends are at word edges, else at the first place in the run of equal
* text it could be in where they are (placeAtWordEdge), else where the diff
* put it. The first:
Expand All @@ -791,7 +806,7 @@ export function setTextEnds(
diff.prefix + diff.suffix === Math.min(a.length, b.length) &&
a.length !== b.length
) {
return composing || caretAnchors(a, b, cursor)
return composing || caretPlaces(a, b, cursor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: no @lexical/y unit test fails if this becomes return composing (or if <= in caretPlaces becomes <). setTextEnds('t1z t2z t3z', 't1z t3z', 5, false) → {prefix: 5, suffix: 2} and setTextEnds('ab ab', 'ab abz ab', 9, false) → {prefix: 5, suffix: 0} would pin the branch that keeps a caret's placement ({prefix: 3, suffix: 4} and {prefix: 2, suffix: 3} without it).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 9659452: setTextEnds("t1z t2z t3z", "t1z t3z", 5) gives {prefix: 5, suffix: 2} and setTextEnds("ab ab", "ab abz ab", 9) gives {prefix: 5, suffix: 0}, so dropping the caret branch or turning <= into < fails.

? diff
: (placeAtWordEdge(a, b, diff, true) ?? diff);
}
Expand Down
Loading