From cf47bdfb56ab12bc2723cc29d6471dc9421f1b2c Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 18:13:21 -0700 Subject: [PATCH 1/2] [lexical-website][lexical-y] Bug Fix: Place an insertion or a deletion of text set without a caret at a word edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text set whole without a caret (`setTextContent` from code, a node's text set again) is diffed against the old text. For an insertion or a deletion, the diff took the common start first, so where equal letters allowed it put the edit inside words: `t1z t2z t3z` set to `t1z t3z` deleted `2z t`. A collaborator's concurrent delete of `t2z` then left `t1z 3z` on every client (a letter of the untouched `t3z` lost), and a word they typed after `t2z` split `t3z` (`t1z t q9z3z`). An insertion or a deletion of set text now goes to the first word edge of the run of equal text it could be in (where it changes no word it does not add or remove), unless the caret placed it, which keeps priority as before. The first edge deletes a word with the space before it (` t2z`), so a word a collaborator types after it at the same time, as typing goes (a space, then the word), keeps the space after it and stays a word of its own. The placement is the one text made again already used (#9598), shared now as `placeAtWordEdge`; set text keeps every other character as before (no whole-word widening), and replacements still snap to whole words (D30). What remains is documented in y.md: a word a collaborator types right against the deleted word on the side of the space deleted with it (before it; after a line's first word, which has no space before it) is glued to the word beyond. D30 records the placement. Closes #9636 `regressions/SetTextWordEdges.test.ts`: fixed cases (both delete `t2z`; the collaborator types ` q9z` after `t2z`; both delete the third of four words; the set adds a word while the collaborator deletes the first), concurrent and unread, both id orders; and a property over random lines of words that share letters, set with one word deleted or inserted while a collaborator deletes a word or types one after or before a word, 150 seeds per cell: every word whole, deleted words stay deleted, added words once, every client and a fresh reader agree; the documented glue counted apart. On main (551fe4a2): ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts × both delete t2z, concurrent (ids 1,2) × the peer types " q9z" after t2z, concurrent (ids 1,2) × both delete t3z of four, concurrent (ids 1,2) ... (12 fixed cells) × concurrent: one word deleted or inserted by setTextContent ‖ the peer deletes a word × concurrent: one word deleted or inserted by setTextContent ‖ the peer type afters a word × concurrent: one word deleted or inserted by setTextContent ‖ the peer type befores a word ... (6 property cells: 69 sessions fail on delete, 27 on typing after, 38 on typing before, of 300 each) Tests 18 failed | 4 passed (22) AssertionError: expected [ 't1z 3z', 't1z 3z', 't1z 3z' ] to deeply equal [ 't1z t3z', 't1z t3z', 't1z t3z' ] ``` ``` $ LEXICAL_Y_VERBOSE=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts Tests 22 passed (22) glued: delete 0 + 0, type after 5 + 3, type before 11 + 16 (of 150 per cell; documented) $ pnpm run ci-check EXIT 0 $ vitest --run --project unit --project scripts-unit Test Files 637 passed | 4 skipped (641) Tests 27180 passed | 166 expected fail | 135 skipped (27481) $ MATRIX_FULL=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/matrix Tests 11 failed | 15639 passed | 5 expected fail | 128 skipped (15783) (the same 11 cells as main) $ FUZZ_SEED_START=1 FUZZ_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 1 failed | 123 passed (124) (seed 2277: dup, as on main) $ FUZZ_SEED_START=50000 FUZZ_SEEDS=2000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 7 failed | 77 passed (84) (seeds 50180, 50231, 50372, 51439, 51507, 51972, 51976, as on main) ``` A randomized session generator (remade lines with concurrent comments, formats, typing and deletes, undo; agents writing with `setTextContent`), 500 sessions of 5 rounds, this change vs main: lost words 0 vs 12, mangled words 0 vs 19, split attributes 0 vs 1; no class higher than on main. --- .changeset/lexical-y-set-text-word-edges.md | 7 + .../lexical-website/docs/collaboration/y.md | 10 +- packages/lexical-y/DECISIONS.md | 3 +- .../unit/regressions/SetTextWordEdges.test.ts | 279 ++++++++++++++++++ packages/lexical-y/src/internal/Inline.ts | 180 +++++++---- packages/lexical-y/src/internal/TextEdits.ts | 20 +- packages/lexical-y/src/internal/Write.ts | 8 +- 7 files changed, 425 insertions(+), 82 deletions(-) create mode 100644 .changeset/lexical-y-set-text-word-edges.md create mode 100644 packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts 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..93771680e06 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -830,7 +830,15 @@ 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 goes to the first word edge where it could be, 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 that word at the same + time, on the side of the space deleted with it, ends up glued to the word + beyond (`q9z` typed before `t2z` shows as `t1zq9z`; on a line's first + word, which has no space 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..ec8d1f65424 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 goes to the first word edge of the run of equal text it could be in** (a word deleted with the space before it). | 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..f6340c3b346 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts @@ -0,0 +1,279 @@ +/** + * 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 {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 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..e908f85bbe5 100644 --- a/packages/lexical-y/src/internal/Inline.ts +++ b/packages/lexical-y/src/internal/Inline.ts @@ -652,6 +652,125 @@ 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: where the caret placed it (it stopped the common + * start) if that changes no word; else at the first such place with + * `first`, or there if diffStrings put it at such a place, else at the last + * one; 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)); + }; + // 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 (caretPlaced || !first) { + if (changesNoWord(diff.prefix)) { + return diff; + } + if (caretPlaced) { + return null; + } + } + for ( + let k = first ? n - end : hi; + first ? k <= hi : k >= n - end; + k += first ? 1 : -1 + ) { + if (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, at + * the first word edge in the run of equal text it could be in, unless the + * caret placed it (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 ? 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 +805,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; From 862ecca7caaeaeda67986b51bc39e7669fe42a04 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 18:52:46 -0700 Subject: [PATCH 2/2] [lexical-website][lexical-y] Bug Fix: Move an edit of set text only where the diff puts it inside a word, only between word edges, and never past a caret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Follow-up from review of 09b721f1. Whether the caret placed an insertion or a deletion in set text was inferred from where the diff stopped its common start (before the furthest place it could reach). A caret at the rightmost place of the run of equal text stops nothing, so its placement was taken for a guess and the edit moved to the first word edge: typing a space after two spaces (`a b` to `a b`, caret at 4) inserted it before them, and a collaborator's concurrent `Q` between the old spaces showed as `a Q b` instead of `a Q b`. `setTextEnds` now asks whether the caret anchors the edit (the text after it is unchanged: the condition diffStrings already uses, now one function, `caretAnchors`), and keeps the diff's placement whenever it does; only an edit no caret anchors goes to the first word edge. Second, an edit moved from where the diff put it was accepted with only one of its ends at a word edge, so it could take a piece of a word that stays: `a ba c` set to `a c` deleted `a b` (keeping the `a` of `ba` as the first word), and a collaborator's concurrent delete of `ba` left ` c`; `one someone end` set to `one end` lost `one` the same way, and the insertion mirror (`a c` set to `a ba c` while a collaborator deletes the old `a`) left `a b c`. A moved edit of set text must now start and end at word edges of the longer text (` ba` here); where no place does, the diff's placement stays. Text made again (#9598, #9625) keeps its own rule. Third, an edit already at word edges where the diff put it was moved too, to the first edge: an edit and a set in one update are composed without a caret, so a space added to two spaces (`Xa b` to `Xa b`) went before them, and a collaborator's `Q` between the old spaces showed after them. An edit now moves only where the diff puts it inside a word; everything else keeps main's placement. y.md and D30 say so, and that the glue left after a word deleted next to concurrent typing happens with any separator (a space, a tab, a comma, an emoji), as on main on the other side. ## Test plan New cells in `SetTextWordEdges.test.ts`: `setTextEnds` with a valid caret equals the caret-anchored diff (spaces, repeated letters, emoji, Han, punctuation, a word); a space typed after two spaces with the caret while a collaborator types between them, recorded and diffed by the writer, both id orders; a word set away (or added) next to a word with the same ending while a collaborator deletes one of them (`a ba c`, `one someone end`, `x ax ab`, and the insertions), both id orders; a letter typed and the text set in one update with a space added to two spaces while a collaborator types between them. Rebased on main with #9625. ### Before On 09b721f1: ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t caret × "a b" set to "a b" with the caret at 4 × "aaa" set to "aaaa" with the caret at 4 × "😀😀" set to "😀😀😀" with the caret at 6 × "哈哈" set to "哈哈哈" with the caret at 3 × "a...b" set to "a....b" with the caret at 5 × a space typed after two spaces ‖ the peer types between them, recorded (ids 1,2) × a space typed after two spaces ‖ the peer types between them, diffed by the writer (ids 1,2) × a space typed after two spaces ‖ the peer types between them, recorded (ids 2,1) × a space typed after two spaces ‖ the peer types between them, diffed by the writer (ids 2,1) Tests 9 failed | 1 passed | 34 skipped (44) $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t 'same ending' Tests 10 failed | 34 skipped (44) $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t 'letter typed' Tests 2 failed | 42 skipped (44) ``` On main (551fe4a2) the same-ending cells fail 2 of 10 (`x ax ab` set to `x ab` while a collaborator deletes `ax` leaves `x b`, the original #9636 shape). ### After ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts Tests 44 passed (44) $ pnpm run ci-check EXIT 0 $ vitest --run --project unit --project scripts-unit Test Files 644 passed | 4 skipped (648) Tests 27729 passed | 204 expected fail | 135 skipped (28068) $ MATRIX_FULL=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/matrix Tests 11 failed | 15998 passed | 5 expected fail | 128 skipped (16142) (the same 11 cells as main) $ FUZZ_SEED_START=1 FUZZ_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 1 failed | 123 passed (124) (seed 2277: dup, as on main) $ FUZZ_SEED_START=50000 FUZZ_SEEDS=2000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 7 failed | 77 passed (84) (seeds 50180, 50231, 50372, 51439, 51507, 51972, 51976, as on main) --- .../lexical-website/docs/collaboration/y.md | 19 +-- packages/lexical-y/DECISIONS.md | 2 +- .../unit/regressions/SetTextWordEdges.test.ts | 132 ++++++++++++++++++ packages/lexical-y/src/internal/Inline.ts | 67 ++++++--- 4 files changed, 191 insertions(+), 29 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 93771680e06..548778ffca3 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -831,14 +831,17 @@ person's: 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 goes to the first word edge where it could be, 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 that word at the same - time, on the side of the space deleted with it, ends up glued to the word - beyond (`q9z` typed before `t2z` shows as `t1zq9z`; on a line's first - word, which has no space before it, text typed after it). A replacement - found that way replaces whole words + 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 ec8d1f65424..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. **A diffed insertion or deletion without a caret goes to the first word edge of the run of equal text it could be in** (a word deleted with the space before it). | 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 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] | diff --git a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts index f6340c3b346..d3784012d4f 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts @@ -27,6 +27,7 @@ import { } 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 = () => @@ -122,6 +123,137 @@ describe('#9636: a word set away with setTextContent ‖ a collaborator edits ne } }); +// 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; diff --git a/packages/lexical-y/src/internal/Inline.ts b/packages/lexical-y/src/internal/Inline.ts index e908f85bbe5..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; @@ -655,10 +665,10 @@ 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: where the caret placed it (it stopped the common - * start) if that changes no word; else at the first such place with - * `first`, or there if diffStrings put it at such a place, else at the last - * one; null where every place is inside a word (between two letters or + * 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` @@ -694,6 +704,14 @@ function placeAtWordEdge( // `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`). @@ -722,13 +740,18 @@ function placeAtWordEdge( ) { end++; } - // Where the caret placed it (it stopped the common start), there. - const caretPlaced = diff.prefix < hi; - if (caretPlaced || !first) { + 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; } - if (caretPlaced) { + // Where the caret placed it (it stopped the common start), there. + if (diff.prefix < hi) { return null; } } @@ -737,7 +760,7 @@ function placeAtWordEdge( first ? k <= hi : k >= n - end; k += first ? 1 : -1 ) { - if (changesNoWord(k)) { + if (first ? moves(k) : changesNoWord(k)) { return {prefix: k, suffix: n - k}; } } @@ -748,9 +771,11 @@ function placeAtWordEdge( * 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, at - * the first word edge in the run of equal text it could be in, unless the - * caret placed it (placeAtWordEdge), else where the diff put it. The first: + * (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. @@ -766,7 +791,9 @@ export function setTextEnds( diff.prefix + diff.suffix === Math.min(a.length, b.length) && a.length !== b.length ) { - return composing ? diff : (placeAtWordEdge(a, b, diff, true) ?? diff); + return composing || caretAnchors(a, b, cursor) + ? diff + : (placeAtWordEdge(a, b, diff, true) ?? diff); } return snapToWords(a, b, diff.prefix, diff.suffix, composing); }