diff --git a/.changeset/lexical-y-set-text-word-edges.md b/.changeset/lexical-y-set-text-word-edges.md new file mode 100644 index 00000000000..c39d90fb527 --- /dev/null +++ b/.changeset/lexical-y-set-text-word-edges.md @@ -0,0 +1,7 @@ +--- +'@lexical/y': patch +--- + +[fix] Place an insertion or a deletion found in text set without a caret at a word edge, so a collaborator's concurrent edit of the same word does not take a letter of the next one + +Verify: `setTextContent` (or a node's text set whole) that deletes or inserts whole words, with no caret in the node, while a collaborator deletes or edits the same words: `t1z t2z t3z` set to `t1z t3z` while a collaborator deletes `t2z` shows `t1z t3z` on every client, not `t1z 3z`. diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 6933a83a48a..548778ffca3 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -830,7 +830,18 @@ 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; a replacement found that way replaces whole words + 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; + 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 + delete of `t2z` leaves `t1z t3z`). Text a collaborator types right + against a word deleted that way at the same time, on the side of the + separator deleted with it (a space, a tab, a comma, an emoji), ends up + glued to the word beyond, as it does without this placement on the other + side (`q9z` typed before `t2z` shows as `t1zq9z`; on a line's first + word, which has no separator before it, text typed after it). A + replacement found that way replaces whole words (what a collaborator types inside the replaced words at the same time lands in one piece at the start of the new text, and the letters the new text shares are written again, so a collaborator's formatting of them at diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index dd3de43a433..961e0b13c56 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -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, about 30 ms for a 5,000-word line; past it, only the start and end; 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. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). | `DECIDED` | 10-26 | [#9400], [#9467] | +| 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] | | 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] | @@ -201,3 +201,4 @@ issues and docs that carry it. [#9610]: https://github.com/meta/lexical/issues/9610 [#9621]: https://github.com/meta/lexical/issues/9621 [#9635]: https://github.com/meta/lexical/issues/9635 +[#9636]: https://github.com/meta/lexical/issues/9636 diff --git a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts new file mode 100644 index 00000000000..d3784012d4f --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts @@ -0,0 +1,411 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +// #9636: `setTextContent` (and any text set whole without a caret) found an +// insertion or a deletion by its common start first, so where equal letters +// allowed it the edit landed inside words: `t1z t2z t3z` set to `t1z t3z` +// deleted `2z t`, and a collaborator's concurrent delete of `t2z` left +// `t1z 3z`. An insertion or a deletion of set text now goes to a word edge of +// the run of equal text it could be in, the first one (a word deleted with +// the space before it), unless the caret placed it. + +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $setSelection, + type ElementNode, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import {diffStrings, setTextEnds, snapToWords} from '../../../internal/Inline'; +import {expectConverged, flush, freshReader, setup} from '../../utils/harness'; + +const $node = () => + ( + $getRoot().getFirstChildOrThrow() as ElementNode + ).getFirstChildOrThrow() as TextNode; +const $splice = (at: number, n: number, insert: string) => () => { + const node = $node(); + const s = $createRangeSelection(); + s.anchor.set(node.getKey(), at, 'text'); + s.focus.set(node.getKey(), at + n, 'text'); + $setSelection(s); + if (insert === '') { + s.removeText(); + } else { + s.insertText(insert); + } + $setSelection(null); +}; + +const IDS = [ + [1, 2], + [2, 1], +]; + +describe('#9636: a word set away with setTextContent ‖ a collaborator edits next to it', () => { + for (const ids of IDS) { + for (const [name, base, set, at, n, insert, expected] of [ + ['both delete t2z', 't1z t2z t3z', 't1z t3z', 4, 3, '', 't1z t3z'], + [ + 'the peer types " q9z" after t2z', + 't1z t2z t3z', + 't1z t3z', + 7, + 0, + ' q9z', + 't1z q9z t3z', + ], + [ + 'both delete t3z of four', + 't1z t2z t3z t4z', + 't1z t2z t4z', + 8, + 3, + '', + 't1z t2z t4z', + ], + [ + 'set adds t2z ‖ the peer deletes t1z', + 't1z t3z', + 't1z t2z t3z', + 0, + 3, + '', + ' t2z t3z', + ], + ] as [string, string, string, number, number, string, string][]) { + for (const timing of ['concurrent', 'unread'] as const) { + test(`${name}, ${timing} (ids ${ids})`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode(base)), + ); + }, + {ids}, + ); + t.offline(); + const $set = () => { + $node().setTextContent(set); + $setSelection(null); + }; + if (timing === 'unread') { + t.b.update($splice(at, n, insert)); + t.a.editor.update($set); + await t.sync(); + await flush(); + await t.sync(); + } else { + t.a.update($set); + t.b.update($splice(at, n, insert)); + await t.sync(); + } + const fresh = await freshReader(t.a); + const texts = [...t.peers, fresh].map(p => + p.read(() => $getRoot().getTextContent()), + ); + expect(texts).toEqual(texts.map(() => expected)); + await expectConverged(t.peers); + }); + } + } + } +}); + +// A caret that places the edit keeps placing it (the text after it is +// unchanged), wherever it is in the run of equal text, the rightmost place +// included: typing a space after two spaces keeps the old two together. +describe('#9636: a caret keeps placing the edit', () => { + for (const [a, b, cursor] of [ + ['a b', 'a b', 4], + ['aaa', 'aaaa', 4], + ['😀😀', '😀😀😀', 6], + ['哈哈', '哈哈哈', 3], + ['a...b', 'a....b', 5], + ['t1z t2z t3z', 't1z t3z', 4], + ] as [string, string, number][]) { + test(`${JSON.stringify(a)} set to ${JSON.stringify(b)} with the caret at ${cursor}`, () => { + const diff = diffStrings(a, b, cursor); + expect(setTextEnds(a, b, cursor, false)).toEqual( + snapToWords(a, b, diff.prefix, diff.suffix, false), + ); + }); + } + for (const ids of IDS) { + for (const record of [true, false]) { + test(`a space typed after two spaces ‖ the peer types between them, ${record ? 'recorded' : 'diffed by the writer'} (ids ${ids})`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('a b')), + ); + }, + {ids}, + ); + const recording = t.a.editor._recordTextEdits; + try { + t.a.editor._recordTextEdits = record; + t.a.editor.update(() => $node().select(3, 3), {discrete: true}); + t.offline(); + t.a.update(() => { + $node().setTextContent('a b'); + $node().select(4, 4); + }); + t.b.update(() => { + $node().spliceText(2, 0, 'Q', false); + $setSelection(null); + }); + await t.sync(); + const fresh = await freshReader(t.a); + expect( + [...t.peers, fresh].map(p => + p.read(() => $getRoot().getTextContent()), + ), + ).toEqual(['a Q b', 'a Q b', 'a Q b']); + await expectConverged(t.peers); + } finally { + t.a.editor._recordTextEdits = recording; + } + }); + } + // An edit and a set in one update are composed without a caret: a + // space added to two spaces stays where the diff puts it, at word + // edges already. + test(`a letter typed, then the text set with a space added to two spaces ‖ the peer types between them (ids ${ids})`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('a b')), + ); + }, + {ids}, + ); + t.offline(); + t.a.update(() => { + $node().spliceText(0, 0, 'X', false); + $node().setTextContent('Xa b'); + $setSelection(null); + }); + t.b.update(() => { + $node().spliceText(2, 0, 'Q', false); + $setSelection(null); + }); + await t.sync(); + const fresh = await freshReader(t.a); + expect( + [...t.peers, fresh].map(p => p.read(() => $getRoot().getTextContent())), + ).toEqual(['Xa Q b', 'Xa Q b', 'Xa Q b']); + 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 +// word, so a collaborator's concurrent delete of `ba` took the first `a`). +describe('#9636: a word set away next to a word with the same ending', () => { + for (const ids of IDS) { + for (const [base, set, del, want] of [ + ['a ba c', 'a c', 'ba', ['a', 'c']], + ['one someone end', 'one end', 'someone', ['one', 'end']], + ['x ax ab', 'x ab', 'ax', ['x', 'ab']], + ['a c', 'a ba c', 'a', ['ba', 'c']], + ['one end', 'one someone end', 'one', ['someone', 'end']], + ] as [string, string, string, string[]][]) { + test(`"${base}" set to "${set}" ‖ the peer deletes "${del}" (ids ${ids})`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode(base)), + ); + }, + {ids}, + ); + t.offline(); + t.a.update(() => { + $node().setTextContent(set); + $setSelection(null); + }); + const at = (' ' + base + ' ').indexOf(' ' + del + ' '); + t.b.update($splice(at, del.length, '')); + await t.sync(); + const fresh = await freshReader(t.a); + const texts = [...t.peers, fresh].map(p => + p.read(() => $getRoot().getTextContent()), + ); + const words = texts[0].split(/ +/).filter(Boolean); + expect(new Set(texts).size).toBe(1); + expect(words).toEqual(want); + await expectConverged(t.peers); + }); + } + } +}); + +/** A seeded random integer below `n`. */ +function random(seed: number): (n: number) => number { + let s = seed >>> 0; + return n => { + s = (s + 0x6d2b79f5) >>> 0; + let x = Math.imul(s ^ (s >>> 15), s | 1); + x ^= x + Math.imul(x ^ (x >>> 7), x | 61); + return Math.floor((((x ^ (x >>> 14)) >>> 0) / 4294967296) * n); + }; +} + +const SEEDS = Number(process.env.LEXICAL_Y_SEEDS || 150); + +// The property: a line of words that share letters (`tz`, one or two +// digits) is set with setTextContent to the line with one word deleted or +// inserted, while a collaborator deletes a word, or types a word after a +// word (a space, then the word) or before one (the word, then a space), +// concurrently or before the setter read it, in either client id order. +// Every word of the result is a whole word (no word split or glued to +// another), a word either of them deleted stays deleted, a word either of +// them added is there once, and every client and a fresh reader agree. One +// exception, counted apart and documented (y.md, Limits): a word typed right +// against a word the set deletes, on the side of the space the set deletes +// with it, is glued to the word beyond (`t1z t2z t3z` set to `t1z t3z` +// deletes ` t2z`, and a word typed before `t2z` joins `t1z`; a line's first +// word has no space before it, so its space after it goes). +describe('#9636: set text and a collaborator edit keep every word whole', () => { + for (const timing of ['concurrent', 'unread'] as const) { + for (const edit of ['delete', 'type after', 'type before'] as const) { + test(`${timing}: one word deleted or inserted by setTextContent ‖ the peer ${edit}s a word`, async () => { + const failures: string[] = []; + let gluedCount = 0; + for (let seed = 1; seed <= SEEDS; seed++) { + const rnd = random( + seed * 104729 + (timing === 'unread' ? 7 : 0) + edit.length, + ); + const count = 3 + rnd(5); + const numbers = new Set(); + while (numbers.size < count + 2) { + numbers.add(1 + rnd(30)); + } + const [made, typed, ...words] = [...numbers].map(x => `t${x}z`); + const old = words.join(' '); + const target = rnd(words.length); + const insertSet = rnd(2) === 0; + const next = insertSet + ? [...words.slice(0, target), made, ...words.slice(target)] + : words.filter((_, i) => i !== target); + // The peer's word: any old word (for a delete, one the set keeps). + const choices = words.filter( + (w, i) => edit !== 'delete' || insertSet || i !== target, + ); + const victim = choices[rnd(choices.length)]; + const vAt = + old.split(' ').slice(0, words.indexOf(victim)).join(' ').length + + (words.indexOf(victim) > 0 ? 1 : 0); + const ids = rnd(2) === 0 ? [1, 2] : [2, 1]; + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode(old)), + ); + }, + {ids}, + ); + t.offline(); + const $set = () => { + $node().setTextContent(next.join(' ')); + $setSelection(null); + }; + const $peer = + edit === 'delete' + ? $splice(vAt, victim.length, '') + : edit === 'type after' + ? $splice(vAt + victim.length, 0, ` ${typed}`) + : $splice(vAt, 0, `${typed} `); + if (timing === 'unread') { + t.b.update($peer); + t.a.editor.update($set); + await t.sync(); + await flush(); + await t.sync(); + } else { + t.a.update($set); + t.b.update($peer); + await t.sync(); + } + const fresh = await freshReader(t.a); + const texts = [...t.peers, fresh].map(p => + p.read(() => $getRoot().getTextContent()), + ); + const got = texts[0].split(/ +/).filter(Boolean); + const problems: string[] = []; + 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]; + 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), + ); + for (const w of got.filter(glued)) { + got.splice( + got.indexOf(w), + 1, + ...(w.startsWith(typed) + ? [typed, w.slice(typed.length)] + : [w.slice(0, -typed.length), typed]), + ); + gluedCount++; + } + for (const w of got) { + if (!/^t\d+z$/.test(w)) { + problems.push(`"${w}" not a word`); + } + } + const want = new Set(next); + if (edit === 'delete') { + want.delete(victim); + } else { + want.add(typed); + } + for (const w of want) { + if (got.filter(x => x === w).length !== 1) { + problems.push(`"${w}" x${got.filter(x => x === w).length}`); + } + } + for (const w of got) { + if (!want.has(w)) { + problems.push(`"${w}" came back`); + } + } + if (problems.length > 0) { + failures.push( + `seed ${seed} ids ${ids} "${old}" -> "${next.join(' ')}"; ${edit} "${victim}": ${JSON.stringify(texts[0])}: ${problems.join(', ')}`, + ); + } + } + expect(failures).toEqual([]); + if (process.env.LEXICAL_Y_VERBOSE) { + // eslint-disable-next-line no-console + console.log(`glued ${gluedCount} of ${SEEDS}`); + } + }, 120_000); + } + } +}); diff --git a/packages/lexical-y/src/internal/Inline.ts b/packages/lexical-y/src/internal/Inline.ts index 36d02f16d50..09bc3372c83 100644 --- a/packages/lexical-y/src/internal/Inline.ts +++ b/packages/lexical-y/src/internal/Inline.ts @@ -475,6 +475,20 @@ export function diffAtoms( return {prefix, suffix}; } +/** + * Whether the caret at `cursor` of `b` places an edit that makes `b` of `a` + * (see diffStrings): the text after it is unchanged. + */ +function caretAnchors(a: string, b: string, cursor: number): boolean { + const delta = b.length - a.length; + return ( + cursor >= 0 && + cursor <= b.length && + cursor - delta >= 0 && + b.substring(cursor) === a.substring(cursor - delta) + ); +} + /** Common prefix and suffix of two strings, the prefix limited by a cursor. */ export function diffStrings( a: string, @@ -501,13 +515,9 @@ export function diffStrings( // the change before the caret, where the user made it. // That holds only while the text after the caret is unchanged. const delta = b.length - a.length; - const limit = - cursor >= 0 && - cursor <= b.length && - cursor - delta >= 0 && - b.substring(cursor) === a.substring(cursor - delta) - ? Math.max(0, Math.min(cursor - Math.max(0, delta), max)) - : max; + const limit = caretAnchors(a, b, cursor) + ? Math.max(0, Math.min(cursor - Math.max(0, delta), max)) + : max; let prefix = forward(0, limit); let suffix = 0; const count = max - prefix; @@ -652,6 +662,142 @@ export function snapToWords( // line break is a node of its own, not a separator). const NOT_SPACE = /\S|\n/; +/** + * Where an insertion or a deletion that diffStrings found (`diff` covers the + * whole shorter text) changes no word, sliding it within the run of equal + * text it could be in. There if diffStrings put it at such a place (with + * `first`, both its ends at word edges); else, unless the caret placed it + * (it stopped the common start), at the first such place with `first`, at + * the last one without; null where every place is inside a word (between two letters or + * digits of one, for an insertion; with both ends inside words, for a + * deletion), or would split a surrogate pair. Without a caret, a diff takes + * the common start first, so `t1z t2z t3z` made `t1z t3z` deletes `2z t` + * (and a collaborator's concurrent delete of `t2z` then leaves `t1z 3z`); + * here it deletes ` t2z` (`first`) or `t2z `. + */ +function placeAtWordEdge( + a: string, + b: string, + diff: {prefix: number; suffix: number}, + first = false, +): {prefix: number; suffix: number} | null { + const n = Math.min(a.length, b.length); + // An insertion or a deletion of `m` characters at `k`: of `b` at + // `[k, k + m)` (an insertion) or of `a` (a deletion). + const m = Math.abs(b.length - a.length); + const longer = b.length > a.length ? b : a; + const splits = (s: string, i: number): boolean => + i > 0 && + i < s.length && + isHighSurrogate(s.charCodeAt(i - 1)) && + isLowSurrogate(s.charCodeAt(i)); + // Inside a word: between two letters or digits of one (`t9|z`), not + // next to punctuation, an emoji or white space, and not in a script + // written without spaces, where each character is a word. + const inside = (i: number): boolean => isAlnum(a, i - 1) && isAlnum(a, i); + const changesNoWord = (k: number): boolean => { + if (splits(a, k) || splits(longer, k) || splits(longer, k + m)) { + return false; + } + // An insertion inside a word, or a deletion with both ends inside + // words, changed those words; at a word's edge (`foo` made again as + // `food`, `fooX.bar`) it is a word's text typed or deleted there. + return longer === b ? !inside(k) : !(inside(k) && inside(k + m)); + }; + // Moved from where the diff put it, the edit must start and end at word + // edges of the longer text, so that it neither takes nor gives a piece of + // a word that stays: `a ba c` made `a c` deletes ` ba`, not `a b` (which + // would keep the `a` of `ba` as the first word). + const edge = (i: number): boolean => + !(isAlnum(longer, i - 1) && isAlnum(longer, i)); + const moves = (k: number): boolean => + changesNoWord(k) && edge(k) && edge(k + m); + // The places it could be: from the furthest the common end reaches to + // the furthest the common start reaches (diffStrings takes the common + // start first unless the caret stops it, at `diff.prefix`). + const CHUNK = 256; + let hi = diff.prefix; + while ( + hi + CHUNK <= n && + a.substring(hi, hi + CHUNK) === b.substring(hi, hi + CHUNK) + ) { + hi += CHUNK; + } + while (hi < n && a.charCodeAt(hi) === b.charCodeAt(hi)) { + hi++; + } + let end = diff.suffix; + while ( + end + CHUNK <= n && + a.substring(a.length - end - CHUNK, a.length - end) === + b.substring(b.length - end - CHUNK, b.length - end) + ) { + end += CHUNK; + } + while ( + end < n && + a.charCodeAt(a.length - 1 - end) === b.charCodeAt(b.length - 1 - end) + ) { + end++; + } + if (first) { + // Kept where the diff put it unless that takes or gives a piece of a + // word (see `moves`). + if (moves(diff.prefix)) { + return diff; + } + } else { + if (changesNoWord(diff.prefix)) { + return diff; + } + // Where the caret placed it (it stopped the common start), there. + if (diff.prefix < hi) { + return null; + } + } + for ( + let k = first ? n - end : hi; + first ? k <= hi : k >= n - end; + k += first ? 1 : -1 + ) { + if (first ? moves(k) : changesNoWord(k)) { + return {prefix: k, suffix: n - k}; + } + } + return null; +} + +/** + * 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 + * 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: + * a word deleted with the space before it (` t2z` of `t1z t2z t3z`), so + * text a collaborator types after it at the same time, as typing goes (a + * space, then a word), keeps the space after it and stays a word of its own. + */ +export function setTextEnds( + a: string, + b: string, + cursor: number, + composing: boolean, +): {prefix: number; suffix: number} { + const diff = diffStrings(a, b, cursor); + if ( + diff.prefix + diff.suffix === Math.min(a.length, b.length) && + a.length !== b.length + ) { + return composing || caretAnchors(a, b, cursor) + ? diff + : (placeAtWordEdge(a, b, diff, true) ?? diff); + } + return snapToWords(a, b, diff.prefix, diff.suffix, composing); +} + /** * The common start and end that text made in place of removed text (`b`, * see TextReplay#stretch) keeps of that text (`a`), as the characters they @@ -686,67 +832,12 @@ export function keptEnds( if (composing || a.length === b.length) { return diff; } - // An insertion or a deletion of `m` characters at `k`: of `b` at - // `[k, k + m)` (an insertion) or of `a` (a deletion). const m = Math.abs(b.length - a.length); const longer = b.length > a.length ? b : a; - const splits = (s: string, i: number): boolean => - i > 0 && - i < s.length && - isHighSurrogate(s.charCodeAt(i - 1)) && - isLowSurrogate(s.charCodeAt(i)); - // Inside a word: between two letters or digits of one (`t9|z`), not - // next to punctuation, an emoji or white space, and not in a script - // written without spaces, where each character is a word. const inside = (i: number): boolean => isAlnum(a, i - 1) && isAlnum(a, i); - const changesNoWord = (k: number): boolean => { - if (splits(a, k) || splits(longer, k) || splits(longer, k + m)) { - return false; - } - // An insertion inside a word, or a deletion with both ends inside - // words, changed those words; at a word's edge (`foo` made again as - // `food`, `fooX.bar`) it is a word's text typed or deleted there. - return longer === b ? !inside(k) : !(inside(k) && inside(k + m)); - }; - // The places it could be: from the furthest the common end reaches to - // the furthest the common start reaches (diffStrings takes the common - // start first unless the caret stops it, at `diff.prefix`). - const CHUNK = 256; - let hi = diff.prefix; - while ( - hi + CHUNK <= n && - a.substring(hi, hi + CHUNK) === b.substring(hi, hi + CHUNK) - ) { - hi += CHUNK; - } - while (hi < n && a.charCodeAt(hi) === b.charCodeAt(hi)) { - hi++; - } - let end = diff.suffix; - while ( - end + CHUNK <= n && - a.substring(a.length - end - CHUNK, a.length - end) === - b.substring(b.length - end - CHUNK, b.length - end) - ) { - end += CHUNK; - } - while ( - end < n && - a.charCodeAt(a.length - 1 - end) === b.charCodeAt(b.length - 1 - end) - ) { - end++; - } - // Where the caret placed it (it stopped the common start), there. - const caretPlaced = diff.prefix < hi; - if (changesNoWord(diff.prefix)) { - return diff; - } - if (!caretPlaced) { - for (let k = hi; k >= n - end; k--) { - if (changesNoWord(k)) { - return {prefix: k, suffix: n - k}; - } - } + const placed = placeAtWordEdge(a, b, diff); + if (placed !== null) { + return placed; } // It changed the word or words it is inside of: those are replaced // whole. diff --git a/packages/lexical-y/src/internal/TextEdits.ts b/packages/lexical-y/src/internal/TextEdits.ts index 68132b1f399..f9168f55757 100644 --- a/packages/lexical-y/src/internal/TextEdits.ts +++ b/packages/lexical-y/src/internal/TextEdits.ts @@ -19,7 +19,7 @@ import { type NodeMap, } from 'lexical'; -import {diffStrings, keptStretches, snapToWords} from './Inline'; +import {keptStretches, setTextEnds, snapToWords} from './Inline'; import {$isWrapper} from './Schema'; const __DEV__ = process.env.NODE_ENV !== 'production'; @@ -797,14 +797,7 @@ export class TextReplay { } } } - const diff = diffStrings(d, s, -1); - const {prefix, suffix} = snapToWords( - d, - s, - diff.prefix, - diff.suffix, - this.composing, - ); + const {prefix, suffix} = setTextEnds(d, s, -1, this.composing); // The characters of `d` aligned with deleted text, in order. const cuts: [number, number][] = []; for (let i = 0, pos = 0; i < runs.length; i++) { @@ -1804,14 +1797,7 @@ export class TextReplay { if (made) { keep = keptStretches(w, text, caret, this.composing); } else { - const diff = diffStrings(w, text, caret); - const {prefix, suffix} = snapToWords( - w, - text, - diff.prefix, - diff.suffix, - this.composing, - ); + const {prefix, suffix} = setTextEnds(w, text, caret, this.composing); keep = [ [0, prefix, 0], [tl - suffix, tl, shift], diff --git a/packages/lexical-y/src/internal/Write.ts b/packages/lexical-y/src/internal/Write.ts index 656c24cd145..2f316e73640 100644 --- a/packages/lexical-y/src/internal/Write.ts +++ b/packages/lexical-y/src/internal/Write.ts @@ -42,10 +42,10 @@ import { countLive, type DesiredContext, diffAtoms, - diffStrings, PENDING_LINE_BREAK, readAtoms, type Run, + setTextEnds, snapToWords, } from './Inline'; import { @@ -3141,12 +3141,10 @@ export class Writer { } } } - const diff = diffStrings(oldText, newText, cursor); - const {prefix, suffix} = snapToWords( + const {prefix, suffix} = setTextEnds( oldText, newText, - diff.prefix, - diff.suffix, + cursor, this.state.editor._compositionKey !== null, ); const del = oldText.length - prefix - suffix;