diff --git a/.changeset/lexical-y-undo-run-end.md b/.changeset/lexical-y-undo-run-end.md new file mode 100644 index 00000000000..7bcc2586208 --- /dev/null +++ b/.changeset/lexical-y-undo-run-end.md @@ -0,0 +1,7 @@ +--- +'@lexical/y': patch +--- + +[fix] Stop a format, comment or link from spreading to the end of the document in `@lexical/y` when a collaborator edits inside it while it is undone or its end is deleted + +Verify: undo a format, a comment or a link, or delete the text at its end, while a collaborator types, pastes or changes a format inside it; on every client, no text past it gets the value. diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index f4058747f2c..fa809b6198b 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -841,11 +841,15 @@ not merge as a user might expect: Undoing a format change does not undo a collaborator's later change of part of its range, nor the value that change restored after itself: color `abc`, let a collaborator recolor `b`, then undo the color, and `c` keeps - the old color. Where a collaborator overwrote a step of yours entirely, - the undo manager skips that step as changing nothing, and the same undo - also reverts your step before it. When you undo a format change while a - collaborator removes that format and undoes the removal, the text keeps - the format: the collaborator's undo brings it back. + the old color, up to the end of the range (the same holds for text a + collaborator types or pastes inside the range as you undo). Undoing a link + while a collaborator formats text inside it keeps the link on its text: + formatting text in a link also sets the link's text format, which writes + the link there again. Where a collaborator overwrote a step of yours + entirely, the undo manager skips that step as changing nothing, and the + same undo also reverts your step before it. When you undo a format change + while a collaborator removes that format and undoes the removal, the text + keeps the format: the collaborator's undo brings it back. - **Large lists.** A structural edit in a list costs time proportional to the list on every client. - **The caret when a collaborator rewrites its line.** A caret stays after diff --git a/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts index a5800453dd1..bbc6371f7ec 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts @@ -11,7 +11,7 @@ // the editors are attached to DOM roots, so the reconciler derives elements' // text fields from the text it renders. -import {$createLinkNode, $isLinkNode} from '@lexical/link'; +import {$createLinkNode, $isLinkNode, $toggleLink} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; import {$addMarkID, $removeMarkID} from '@lexical/mark'; import {$patchStyleText} from '@lexical/selection'; @@ -21,7 +21,9 @@ import { $createRangeSelection, $createTextNode, $getRoot, + $getSelection, $isElementNode, + $isRangeSelection, $isTextNode, $setSelection, type ElementNode, @@ -30,7 +32,13 @@ import { } from 'lexical'; import {describe, expect, test} from 'vitest'; -import {freshReader, type Peer, setup, shape} from '../../utils/harness'; +import { + $selectIn, + freshReader, + type Peer, + setup, + shape, +} from '../../utils/harness'; function attach(peer: Peer) { const element = document.createElement('div'); @@ -1424,3 +1432,488 @@ describe('an undo of a format change ‖ its removal by a collaborator, undone', } } }); + +// S7-24, S7-7, S7-10, S7-20, S7-26: a value one client restates (the restore +// after its typed text or its format range, or a value its undo brings back) +// ended at the closer of the change that gave it. The undo of that change by +// its author deleted the closer, and the restated value ran on to the end of +// the document. A run that one client restates now ends with an item of that +// client's own, right after the closer it relied on (see Cursor#restatedEnd +// and YBinding#closeRestoredRuns), for every kind of value: text formats, +// styles, NodeState and its set members, and links. +describe('a value one client restates ‖ the undo of the change that gave it', () => { + const twoLines = (first: string, second: string) => () => + $getRoot().append( + $createParagraphNode().append($createTextNode(first)), + $createParagraphNode().append($createTextNode(second)), + ); + // As the toolbar does: on where the first character lacks it, off otherwise. + const $format = + (format: 'bold' | 'italic' | 'underline', from: number, to: number) => + () => { + const sel = $selectLine(from, to); + sel.format = (sel.anchor.getNode() as TextNode).getFormat(); + $setSelection(sel); + sel.formatText(format); + $setSelection(null); + }; + const $comment = (id: string, from: number, to: number) => () => { + $addMarkID(id, $selectLine(from, to)); + $setSelection(null); + }; + // Plain text typed at first-line offset `at`: a text node of its own, with + // no format or NodeState (as a comment's end does not extend to it). + const $typePlain = (at: number, text: string) => () => { + let acc = 0; + for (const node of ( + $getRoot().getFirstChildOrThrow() as ElementNode + ).getAllTextNodes()) { + const size = node.getTextContentSize(); + if (at <= acc + size) { + const [before] = at - acc < size ? node.splitText(at - acc) : [node]; + before.insertAfter($createTextNode(text)); + return; + } + acc += size; + } + throw new Error('offset'); + }; + // Blocks pasted at first-line offset `at`. + const $paste = + (at: number, ...blocks: (() => LexicalNode)[]) => + () => { + const sel = $selectLine(at, at); + $setSelection(sel); + sel.insertNodes(blocks.map(block => block())); + $setSelection(null); + }; + // Each line's links' text. + const $links = () => + $getRoot() + .getChildren() + .map(p => + ($isElementNode(p) ? p.getChildren() : []) + .filter($isLinkNode) + .map(link => link.getTextContent()) + .join(' '), + ) + .join(' / '); + const $link = (from: number, to: number) => () => { + $setSelection($selectLine(from, to)); + $toggleLink('https://u1.test'); + $setSelection(null); + }; + const $list = () => + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('t20z t67z')), + $createListItemNode().append($createTextNode('t23z')), + ); + for (const ids of [ + [1, 2], + [2, 1], + ]) { + // S7-24: A formats, then undoes, while B types inside the range. + for (const [name, $add, expected] of [ + [ + 'italic', + $format('italic', 0, 9), + '"t56z t85z" " t57z"[I] " t59z t60z" / "t78z t79z"', + ], + [ + 'a comment', + $comment('c1', 0, 9), + '"t56z t85z" " t57z"[c1] " t59z t60z" / "t78z t79z"', + ], + ] as const) { + test(`S7-24: A adds ${name} on "t56z t57z" and undoes it ‖ B types plain text after "t56z" (ids ${ids})`, async () => { + const t = await setup(twoLines('t56z t57z t59z t60z', 't78z t79z'), { + ids, + }); + t.a.update($add); + await t.sync(); + t.offline(); + t.a.undo(); + t.b.update($typePlain(4, ' t85z')); + expect(await merged(t)).toBe(expected); + }); + } + // S7-7: a comment's undo ‖ plain text typed right after it, where + // other values (a comment of a set's other member, bold) end too. + for (const [name, $others, expected] of [ + [ + 'another comment', + [$comment('c5', 10, 14)], + '"t31z t32z " "t33z"[c5] " t1012z t1008z" / "t34z t35z t1005z"', + ], + [ + 'two other comments', + [$comment('c5', 10, 14), $comment('c6', 0, 14)], + '"t31z t32z "[c6] "t33z"[c5+c6] " t1012z t1008z" / "t34z t35z t1005z"', + ], + [ + 'bold', + [$format('bold', 10, 14)], + '"t31z t32z " "t33z"[B] " t1012z t1008z" / "t34z t35z t1005z"', + ], + ] as const) { + test(`S7-7: B comments "t32z t33z", where ${name} ends, and undoes it ‖ A types after "t33z" (ids ${ids})`, async () => { + const t = await setup( + twoLines('t31z t32z t33z t1008z', 't34z t35z t1005z'), + {ids}, + ); + for (const $other of $others) { + t.a.update($other); + } + await t.sync(); + t.b.update($comment('c4', 5, 14)); + await t.sync(); + t.offline(); + t.b.undo(); + t.a.update($typePlain(14, ' t1012z')); + expect(await merged(t)).toBe(expected); + }); + } + // S7-10: a format's undo ‖ its removal from part of the range (the part + // after it keeps the format, up to the end of the range), or from all of + // it, and then the removal's undo (which brings the format back on the + // range). + for (const [format, tag] of [ + ['bold', 'B'], + ['underline', 'U'], + ] as const) { + test(`S7-10: B ${format}s "t6z t7z t8z" and undoes it ‖ A removes it from "t7z" (ids ${ids})`, async () => { + const t = await setup(twoLines('t5z t6z t7z t8z t9z', 'tail line'), { + ids, + }); + t.b.update($format(format, 4, 15)); + await t.sync(); + t.offline(); + t.a.update($format(format, 8, 11)); + t.b.undo(); + expect(await merged(t)).toBe( + `"t5z t6z t7z" " t8z"[${tag}] " t9z" / "tail line"`, + ); + }); + test(`S7-10: B ${format}s "t6z t7z t8z" and undoes it ‖ A removes it, then A undoes that (ids ${ids})`, async () => { + const t = await setup(twoLines('t5z t6z t7z t8z t9z', 'tail line'), { + ids, + }); + t.b.update($format(format, 4, 15)); + await t.sync(); + t.offline(); + t.a.update($format(format, 4, 15)); + t.b.undo(); + await t.sync(); + t.a.undo(); + expect(await merged(t)).toBe( + `"t5z " "t6z t7z t8z"[${tag}] " t9z" / "tail line"`, + ); + }); + } + test(`S7-10: B comments "t9z" and undoes it ‖ A resolves it, then A undoes that (ids ${ids})`, async () => { + const t = await setup(twoLines('t5z t6z t7z t8z t9z', 'tail line'), { + ids, + }); + t.b.update($comment('c2', 16, 19)); + await t.sync(); + t.offline(); + t.a.update(() => $removeMarkID('c2')); + t.b.undo(); + await t.sync(); + t.a.undo(); + expect(await merged(t)).toBe( + '"t5z t6z t7z t8z " "t9z"[c2] / "tail line"', + ); + }); + // The nulls an undo writes after itself (see closeRestoredRuns) are read + // as part of that undo: the selection it puts back stays, also where the + // undo runs inside an editor update (as UNDO_COMMAND's handler does). + for (const nested of [false, true]) { + test(`S7-10: A's undo of a removal of B's undone bold puts back A's selection (${nested ? 'inside an update' : 'direct'}, ids ${ids})`, async () => { + const t = await setup(twoLines('t5z t6z t7z t8z t9z', 'tail line'), { + ids, + }); + t.b.update(() => { + $selectIn([0], 4, 15).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + t.offline(); + t.a.update(() => void $selectIn([0], 4, 15)); + t.a.update(() => { + const sel = $getSelection(); + if (!$isRangeSelection(sel)) { + throw new Error('no range selection'); + } + sel.format = 1; + sel.formatText('bold'); + $selectIn([0], 0, 0); + }); + t.b.undo(); + await t.sync(); + if (nested) { + t.a.editor.update(() => void t.a.binding.undo(), {discrete: true}); + } else { + t.a.binding.undo(); + } + const offsets = t.a.read(() => { + const sel = $getSelection(); + const texts = $getRoot().getAllTextNodes(); + const at = (key: string, offset: number) => { + let acc = 0; + for (const text of texts) { + if (text.getKey() === key) { + return acc + offset; + } + acc += text.getTextContentSize(); + } + return -1; + }; + return $isRangeSelection(sel) + ? [ + at(sel.anchor.key, sel.anchor.offset), + at(sel.focus.key, sel.focus.offset), + ] + : null; + }); + expect(offsets).toEqual([4, 15]); + expect(await merged(t)).toBe( + '"t5z " "t6z t7z t8z"[B] " t9z" / "tail line"', + ); + }); + } + // S7-20: a comment's undo ‖ a list pasted inside the commented words. + test(`S7-20: A comments "t59z t60z t61z" and undoes it ‖ B pastes a list after "t60z" (ids ${ids})`, async () => { + const t = await setup(twoLines('t58z t59z t60z t61z t62z', 't78z t79z'), { + ids, + }); + t.a.update($comment('c9', 5, 19)); + await t.sync(); + t.offline(); + t.a.undo(); + t.b.update($paste(14, $list)); + expect(await merged(t)).toBe( + '"t58z t59z t60z" / "t20z t67z" "t23z" / " t61z"[c9] " t62z" / "t78z t79z"', + ); + }); + // S7-26: a link's undo ‖ a format change over the link's start, which + // also sets the link's textFormat (RangeSelection#formatText): that + // write restates the link over its text, which keeps it there. + for (const format of ['italic', 'bold'] as const) { + test(`S7-26: B links "t7z t8z t9z" and undoes it ‖ A makes "t5z t6z t7z" ${format} (ids ${ids})`, async () => { + const t = await setup( + twoLines('t1z t2z t3z t4z t5z t6z t7z t8z t9z', 'tail line'), + {ids}, + ); + t.b.update(() => { + $setSelection($selectLine(24, 35)); + $toggleLink('https://u0.test'); + $setSelection(null); + }); + await t.sync(); + t.offline(); + t.a.update($format(format, 16, 27)); + t.b.undo(); + await merged(t); + const fresh = await freshReader(t.a); + for (const p of [t.a, t.b, fresh]) { + expect(p.read($links)).toBe('t7z t8z t9z / '); + } + }); + } + } + // S7-26, links only: the run a write opens ends at another client's + // change end, which it reuses as its own closer: it gets a null of its own + // after it too, so that change's undo does not take the run's end away. + for (const ids of [ + [1, 2, 3], + [3, 2, 1], + ]) { + test(`S7-26: B links "t2z t3z t4z" ‖ C links "t3z"; then B undoes it ‖ C links "t2z t3z t4z" (ids ${ids})`, async () => { + const t = await setup( + twoLines('t1z t2z t3z t4z t5z t6z t7z t8z t9z', 'tail line'), + {ids}, + ); + t.offline(); + t.b.update($link(4, 15)); + t.c.update($link(8, 11)); + await t.sync(); + t.offline(); + t.b.undo(); + t.c.update($link(4, 15)); + await merged(t); + const fresh = await freshReader(t.a); + for (const p of [t.a, t.b, t.c, fresh]) { + expect(p.read($links)).toBe('t3z t4z / '); + } + }); + } +}); + +// The undo race matrix of soak7: A adds bold or a comment on "t56z t57z" +// and removes it (an undo, or the opposite change) while B edits inside the +// range. Nothing outside the range gets the value. +describe('a format or comment removed ‖ an edit inside its range', () => { + const $outside = () => + $getRoot() + .getAllTextNodes() + .filter( + n => + /t(59|60|78|79)z/.test(n.getTextContent()) && + (n.hasFormat('bold') || + ((n.exportJSON() as {$?: {markIds?: string[]}}).$?.markIds ?? []) + .length > 0), + ) + .map(n => n.getTextContent()); + const $at = (at: number) => { + const sel = $selectLine(at, at); + $setSelection(sel); + return sel; + }; + const edits: [string, () => void][] = [ + [ + 'types plain text', + () => { + const sel = $at(4); + sel.format = 0; + sel.insertText(' t85z'); + }, + ], + ['presses Enter', () => void $at(4).insertParagraph()], + [ + 'pastes two paragraphs', + () => + void $at(4).insertNodes([ + $createParagraphNode().append($createTextNode('t20z')), + $createParagraphNode().append($createTextNode('t23z')), + ]), + ], + [ + 'pastes a list', + () => + void $at(4).insertNodes([ + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('t20z')), + $createListItemNode().append($createTextNode('t23z')), + ), + ]), + ], + ['deletes "t56z"', () => void $selectLine(0, 5).removeText()], + ]; + const kinds: [string, () => void, () => void][] = [ + [ + 'bold', + () => $selectLine(0, 9).formatText('bold'), + () => { + const sel = $selectLine(0, 9); + sel.format = 1; + sel.formatText('bold'); + }, + ], + [ + 'a comment', + () => $addMarkID('c1', $selectLine(0, 9)), + () => $removeMarkID('c1'), + ], + ]; + for (const [kind, $add, $remove] of kinds) { + for (const undo of [true, false]) { + for (const [edit, $edit] of edits) { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + test(`A adds ${kind} on "t56z t57z" and ${undo ? 'undoes it' : 'removes it'} ‖ B ${edit} after "t56z" (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('t56z t57z t59z t60z'), + ), + $createParagraphNode().append($createTextNode('t78z t79z')), + ), + {ids}, + ); + t.a.update(() => { + $add(); + $setSelection(null); + }); + await t.sync(); + t.offline(); + if (undo) { + t.a.undo(); + } else { + t.a.update(() => { + $remove(); + $setSelection(null); + }); + } + t.b.update(() => { + $edit(); + $setSelection(null); + }); + await merged(t); + expect(t.a.read($outside)).toEqual([]); + }); + } + } + } + } +}); + +// L1B-1: J typed plain text right after a word two comments cover, which +// ends each comment with a null of J's own after the end its author wrote +// (see Cursor#format, rule 5). C pasted over J's word, and the delete +// collapsed the format items after it keeping only J's null of the first +// comment; J's concurrent undo of its typing deleted that null, and the +// comment covered the rest of the document. +describe('L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text', () => { + for (const ids of [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [2, 4, 1, 3], + [3, 1, 4, 2], + ]) { + for (const sameClient of [true, false]) { + test(`L1B-1: the comments end after the word, from ${sameClient ? 'one client' : 'two clients'} (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $createParagraphNode().append($createTextNode('t1z t2')), + $createParagraphNode().append($createTextNode('t3z')), + ), + {ids}, + ); + const [a, b, c, j] = t.peers; + const $comment = (id: string) => () => { + $addMarkID(id, $selectLine(0, 3)); + $setSelection(null); + }; + a.update($comment('c4')); + await t.sync(); + (sameClient ? a : b).update($comment('c12')); + await t.sync(); + for (const peer of t.peers) { + peer.binding.undoManager?.clear(); + } + // Plain text typed right after "t1z", a text node of its own. + j.update(() => { + const [word] = ( + $getRoot().getFirstChildOrThrow() as ElementNode + ).getAllTextNodes(); + word.splitText(3)[0].insertAfter($createTextNode(' t1011z')); + }); + await t.sync(); + t.offline(); + j.undo(); + c.update(() => { + const sel = $selectLine(4, 10); + $setSelection(sel); + sel.insertText('t1013z'); + $setSelection(null); + }); + expect(await merged(t)).toBe('"t1zt1013z"[c12+c4] " t2" / "t3z"'); + }); + } + } +}); diff --git a/packages/lexical-y/src/internal/Binding.ts b/packages/lexical-y/src/internal/Binding.ts index 2ca6eb97389..ab4924b7f86 100644 --- a/packages/lexical-y/src/internal/Binding.ts +++ b/packages/lexical-y/src/internal/Binding.ts @@ -78,7 +78,12 @@ import { LEGACY_ROOTS, SET_MEMBERS_LAYOUT_VERSION, } from './constants'; -import {isFormatCloser, markFormatCloser} from './Cursor'; +import { + changeEnd, + equalFormatValue, + isFormatCloser, + markFormatCloser, +} from './Cursor'; import {$canonicalizeGroup, $switchesWrapper} from './Groups'; import {Hidden, shownLength} from './Hidden'; import {$applyStoredAttrs, Reader} from './Read'; @@ -316,6 +321,24 @@ export interface YCursor { focus: unknown; } +/** + * Whether live format item `item` changes its key's value: the nearest live + * item of its key before it gives another value (none gives null). + */ +function opensRun(item: Item): boolean { + const {key, value} = item.content as ContentFormat; + for (let it = item.left; it !== null; it = it.left) { + if ( + !it.deleted && + it.content instanceof ContentFormat && + it.content.key === key + ) { + return !equalFormatValue(it.content.value, value); + } + } + return value !== null; +} + /** * The layout version `root` records: the highest of its `lexical-y:` * attributes (see LAYOUT_PREFIX), null when it has none, and NaN when one is @@ -538,6 +561,12 @@ export class YBinding { private remoteSeq = 0; /** Origin of read-only scans (no changes). */ private readonly scanOrigin = {binding: this, scan: true}; + /** + * The origin of the nulls an undo or redo writes to end the runs it + * brings back (see closeRestoredRuns): read into the editor as a + * collaborator's change is, and not part of the undo stack. + */ + private readonly runEndOrigin = {binding: this, runEnd: true}; /** Stored nodes the running undo/redo restores (see isKeptByUndo). */ private restoring: Set | null = null; /** Stored nodes the running undo/redo deletes (it inserted them). */ @@ -1772,11 +1801,17 @@ export class YBinding { let cursor = this.pendingCursor; this.pendingCursor = null; // An undo puts back the selection of its step (a range, after typing - // over one); any other read keeps the user's caret a caret. + // over one); any other read keeps the user's caret a caret. The nulls an + // undo writes after itself (see closeRestoredRuns) are read as part of + // it: their read puts back the same selection, which the editor may not + // have committed yet (an undo run inside an update). const undoing = - tr.origin === this._undoManager && this._undoManager !== null; + (tr.origin === this._undoManager && this._undoManager !== null) || + tr.origin === this.runEndOrigin; if (undoing) { - this.undoneCursor = cursor; + if (tr.origin !== this.runEndOrigin) { + this.undoneCursor = cursor; + } cursor = this.stepCursor ?? cursor; } let rebuilt: ReadonlySet = new Set(); @@ -3976,25 +4011,32 @@ export class YBinding { /** * End each run that a format item the undo or redo of `step` brought back - * opens, where a null of another client's ends it, with a null of this - * client's own right before that one. An undo brings back the items its - * change deleted, and the closer that ended such a run was not among - * them: a collaborator's concurrent undo of the change that wrote it - * removed it, and the value ran on to the end of the document (a comment - * added, undone while a collaborator resolved it and undid the resolve, - * put the comment on everything after it). Invariant: a run an undo or - * redo brings back ends where a closer ended it for this client, whatever - * another client removes at the same time. A run that another value ends - * is left as it is: where that value's change is undone, the run going on - * over its text is what it had before (a copy of that value kept it there - * after its own undo). + * opens with an item of this client's own, where the item that ended it is + * another client's: right before a live null of another client's that ends + * it, or right after the end the original's own change gave its run (see + * changeEnd), where that is gone and nothing ends the run before it. An + * undo brings back the items its change deleted, and the closer that ended + * such a run was not among them: a collaborator's concurrent undo of the + * change that wrote it removed it, and the value ran on to the end of the + * document (a comment added, undone while a collaborator resolved it and + * undid the resolve, put the comment on everything after it; bold removed + * from a collaborator's bold text while the collaborator undid it, and that + * removal undone, made everything after it bold). Invariant: a run an undo + * or redo brings back ends where a closer ended it for this client, + * whatever another client removes at the same time. A run that another + * value ends is left as it is: where that value's change is undone, the run + * going on over its text is what it had before (a copy of that value kept + * it there after its own undo). A copy inside a run of its own value (see + * opensRun) opens no run: the run it is in ends where it ends. */ private closeRestoredRuns(step: UndoManager['undoStack'][number]): void { const doc = this._doc; const store = doc.store; const client = doc.clientID; + // Where to write each null: after `left`, before `right`. + const ends: [left: Item | null, right: Item | null, key: string][] = []; doc.transact(tr => { - const copies: Item[] = []; + const copies: [original: Item, copy: Item][] = []; iterateStructsByIdSet(tr, step.deletes, s => { if ( s instanceof Item && @@ -4008,50 +4050,62 @@ export class YBinding { copy.content instanceof ContentFormat && copy.content.value !== null ) { - copies.push(copy); + copies.push([s, copy]); } } }); - for (const copy of copies) { + for (const [original, copy] of copies) { const {key} = copy.content as ContentFormat; + // The end of the run the original opened in its own write (see + // changeEnd): where that is gone (the undo of that write), the + // copy's run ends there. + const gone = opensRun(copy) ? changeEnd(doc, original) : null; let shown = false; - let end: Item | null = null; for (let it = copy.right; it !== null; it = it.right) { + if (it === gone && it.deleted) { + if (shown) { + ends.push([it, it.right, key]); + } + break; + } if (it.deleted) { continue; } if (it.content instanceof ContentFormat) { if (it.content.key === key) { - end = it; + if ( + shown && + it.id.client !== client && + it.content.value === null + ) { + ends.push([it.left, it, key]); + } break; } } else if (it.countable) { shown = true; } } - if ( - end === null || - !shown || - end.id.client === client || - (end.content as ContentFormat).value !== null - ) { - continue; - } - const left = end.left; - const item = new Item( - createID(client, store.getClock(client)), - left, - left && left.lastId, - end, - end.id, - end.parent, - null, - new ContentFormat(key, null as unknown as object), - ); - item.integrate(tr, 0); - markFormatCloser(item); } }, this.scanOrigin); + if (ends.length > 0) { + doc.transact(tr => { + for (const [left, right, key] of ends) { + const item = new Item( + createID(client, store.getClock(client)), + left, + left && left.lastId, + right, + right && right.id, + (left ?? right)!.parent, + null, + new ContentFormat(key, null as unknown as object), + ); + item.integrate(tr, 0); + markFormatCloser(item); + } + }, this.runEndOrigin); + } } /** diff --git a/packages/lexical-y/src/internal/Cursor.ts b/packages/lexical-y/src/internal/Cursor.ts index 8bdcf800c4f..7b9b4fee172 100644 --- a/packages/lexical-y/src/internal/Cursor.ts +++ b/packages/lexical-y/src/internal/Cursor.ts @@ -14,6 +14,7 @@ import { ContentString, ContentType, createID, + type Doc, getItemCleanStart, Item, type Node as YNode, @@ -142,6 +143,117 @@ export const markFormatCloser = (item: Item): void => { formatClosers.add(item); }; +/** + * The item that ends the run `item`, a format item, opened in the write that + * made it: the first item of its key, with another value, that its client + * made after it (one write's items are one after the other in its client's + * clock order; looked for among the next 64). Null when none is found. + */ +export function changeEnd(doc: Doc, item: Item): Item | null { + const {key, value} = item.content as ContentFormat; + const {client} = item.id; + const end = doc.store.getClock(client); + let clock = item.id.clock + item.length; + for (let steps = 0; clock < end && steps < 64; steps++) { + const next = doc.store.getItem(createID(client, clock)); + if (!(next instanceof Item)) { + return null; + } + if ( + next.content instanceof ContentFormat && + next.content.key === key && + !equalFormatValue(next.content.value, value) + ) { + return next; + } + clock = next.id.clock + next.length; + } + return null; +} + +/** + * The item its client made last before `item` (looked for among the 64 + * structs before it, in clock order) of the key `key`, or null. + */ +function lastOwnItemBefore(doc: Doc, item: Item, key: string): Item | null { + const {client} = item.id; + let clock = item.id.clock - 1; + for (let steps = 0; clock >= 0 && steps < 64; steps++) { + const prev = doc.store.getItem(createID(client, clock)); + if (!(prev instanceof Item)) { + return null; + } + if (prev.content instanceof ContentFormat && prev.content.key === key) { + return prev; + } + clock = prev.id.clock - 1; + } + return null; +} + +/** + * Whether null `item` is the end its client gave a run it opened in the + * same write (see changeEnd): the item of its key its client made last + * before it opens a run that ends at `item`. + */ +function isChangeEnd(doc: Doc, item: Item): boolean { + const opener = lastOwnItemBefore(doc, item, keyOf(item)); + return ( + opener !== null && + valueOf(opener) !== null && + changeEnd(doc, opener) === item + ); +} + +/** The nearest live item of `key` before `item`, among the 4096 before it. */ +function liveItemBefore(item: Item, key: string): Item | null { + let steps = 0; + for (let it = item.left; it !== null && steps < 4096; it = it.left, steps++) { + if (!it.deleted && isFormat(it) && keyOf(it) === key) { + return it; + } + } + return null; +} + +/** + * The item that opened the run live format item `item` gives its value in: + * `item` itself, or, where it is a restore (its client wrote it after text + * it inserted inside the run, with a null of the key before that text), the + * opener of the run that restore restates, and so on. + */ +export function runOpener(doc: Doc, item: Item): Item { + const {key, value} = item.content as ContentFormat; + let current = item; + for (let depth = 0; depth < 16; depth++) { + const negation = lastOwnItemBefore(doc, current, key); + if (negation === null || valueOf(negation) !== null) { + return current; + } + let found = false; + let steps = 0; + for ( + let it = current.left; + it !== null && steps < 512; + it = it.left, steps++ + ) { + if (it === negation) { + found = true; + break; + } + if (!it.deleted && isFormat(it) && keyOf(it) === key) { + break; + } + } + const source = found ? liveItemBefore(negation, key) : null; + if (source === null || !equalFormatValue(valueOf(source), value)) { + return current; + } + current = source; + } + return current; +} + /** A live countable item: text, a child node, an embed. */ export const isLiveContent = (item: Item): boolean => !isGone(item) && item.countable; @@ -409,9 +521,14 @@ export class Cursor { * formatFamily) is never written after the items of a unit of it there * (see unitFrom), where a concurrent format change of the content after * puts its own item: it is written before them (see - * minimizeFormatChanges). + * minimizeFormatChanges). A restore of a value whose run another client's + * change opened gets an end of this client's own after that change's end + * (see format, rule 5). */ private insertNegatedFormats(negated: Formats, closers = false): void { + // The keys whose run, which this write opens, ends at another client's + // change end it reuses (see format, rule 5). + const reusedEnds: string[] = []; while (this.right !== null) { const r = this.right; if (!isGone(r)) { @@ -437,17 +554,112 @@ export class Cursor { break; } } + if ( + valueOf(r) === null && + r.id.client !== this.tr.doc.clientID && + isChangeEnd(this.tr.doc, r) + ) { + reusedEnds.push(keyOf(r)); + } negated.delete(keyOf(r)); } this.forward(); } + for (const key of reusedEnds) { + this.right = this.newItem( + new ContentFormat(key, null as unknown as object), + ); + if (closers) { + formatClosers.add(this.right); + } + this.forward(); + } + const ends: Item[] = []; for (const [key, val] of negated) { + const end = val === null ? null : this.restatedEnd(key, val); + if (end !== null) { + ends.push(end); + } this.right = this.newItem(new ContentFormat(key, val as object)); if (closers && val === null) { formatClosers.add(this.right); } this.forward(); } + for (const end of ends) { + this.insertAfter( + end, + new ContentFormat(keyOf(end), null as unknown as object), + ); + } + } + + /** + * For a restore of `key` to `value` written at the cursor: where the run it + * restates was opened by another client's change (see runOpener; the item + * that gave the content the value before this write is the nearest item of + * the key before the cursor that this write did not make), and the next + * live item of the key after the cursor with another value is that change's + * own end of the run (see changeEnd), that end, after which the restore's + * run gets an end of this client's own (see format, rule 5). Null + * otherwise. + */ + private restatedEnd(key: string, value: unknown): Item | null { + const doc = this.tr.doc; + let source: Item | null = null; + let steps = 0; + for ( + let it = this.left; + it !== null && steps < 4096; + it = it.left, steps++ + ) { + if ( + isFormat(it) && + keyOf(it) === key && + !this.tr.insertSet.hasId(it.id) && + (!it.deleted || this.tr.deleteSet.hasId(it.id)) + ) { + source = it; + break; + } + } + if (source === null || !equalFormatValue(valueOf(source), value)) { + return null; + } + const opener = runOpener(doc, source); + if (opener.id.client === doc.clientID) { + return null; + } + for (let it = this.right; it !== null; it = it.right) { + if ( + it.deleted || + !isFormat(it) || + keyOf(it) !== key || + equalFormatValue(valueOf(it), value) + ) { + continue; + } + return valueOf(it) === null && changeEnd(doc, opener) === it ? it : null; + } + return null; + } + + /** Write `content` right after `left`, away from the cursor. */ + private insertAfter(left: Item, content: ContentFormat): Item { + const doc = this.tr.doc; + const right = left.right; + const item = new Item( + createID(doc.clientID, doc.store.getClock(doc.clientID)), + left, + left.lastId, + right, + right && right.id, + this.parent, + null, + content, + ); + item.integrate(this.tr, 0); + return item; } /** @@ -542,7 +754,10 @@ export class Cursor { * there: a format change keeps the item before its opener at a place (see * format), and with several keys a whole value's item there gives every key * its value. Delete the items of the rest, except, of each key, the last item - * where without it the key would have another value after the run. Invariant: + * where without it the key would have another value after the run, and, + * where the key's last item is kept, the last item before it of that key with + * the same value (no other value between them) that another client wrote. + * Invariant: * such a run holds, besides the units of the last delete in it, at most five * units per family, the items that give a family its value at the end of the * places of those, and items that change a key's value between the content @@ -565,7 +780,14 @@ export class Cursor { * at once each left the space's own item, which the other relied on); and the * one before it gives that content the value it falls back to where a * concurrent format change of it removes its item (a comment resolved on a - * character took the comment of the text deleted before it). The items a kept + * character took the comment of the text deleted before it). The other + * client's item with the key's last value stays as each client's undo deletes + * its own items only: a run's end and the null another client wrote right + * after it (see format, rule 5) each end the run where the other's writer + * removes theirs (a paste over plain text typed after a word two comments + * cover kept only the typist's null of one comment, and the typist's + * concurrent undo left that comment on to the end of the document). The + * items a kept * unit's place holds for the other keys of its family give content restored * there, as an undo of its delete restores it, the value it had: a word two * clients gave a comment each, deleted, kept one comment's opener once a @@ -712,14 +934,26 @@ export class Cursor { i = j; } } - // The value of each key after the run stays. + // The value of each key after the run stays; where the key's last item + // stays, so does the last item before it with the same value that + // another client wrote. const lastOf = new Map(); const lastKept = new Map(); + const otherClient = new Map(); for (const unit of units) { for (const it of unit) { - lastOf.set(keyOf(it), it); + const key = keyOf(it); + const before = lastOf.get(key); + if (before !== undefined) { + if (!equalFormatValue(valueOf(before), valueOf(it))) { + otherClient.delete(key); + } else if (before.id.client !== it.id.client) { + otherClient.set(key, before); + } + } + lastOf.set(key, it); if (keep.has(it)) { - lastKept.set(keyOf(it), it); + lastKept.set(key, it); } } } @@ -733,6 +967,10 @@ export class Cursor { ) { keep.add(it); } + const other = otherClient.get(key); + if (keep.has(it) && other !== undefined) { + keep.add(other); + } } const values = new Map(); const changed = new Set(); @@ -847,6 +1085,28 @@ export class Cursor { * there (see minimizeFormatChanges), so a comment resolved over two * characters while a peer typed plain text between them came back on the * second. A key whose old value there is null is restored by no one. + * 5. Where it restores a value (at the end of the range, as after + * inserted text, see insertNegatedFormats) whose run another client's + * change opened (see runOpener), and the item that ends the restored run + * is that change's own end of the run (see changeEnd), it writes a null + * of its own right after that end. Why: that end can go while the + * restore stays, as the undo of that change deletes it and a concurrent + * delete may drop it (it keeps a bounded number of the items it leaves, + * see collapseGap), and the restored value ran on to the end of the + * document (a format undone while a collaborator typed plain text inside + * it, pasted a list there or removed it from part of the range; a + * comment undone while a collaborator typed right after it where another + * comment ended; a comment whose end a long offline peer's delete + * dropped while a collaborator pasted inside it). Right after the end, + * not before it: text a collaborator types at the end of the run at the + * same time lands before the end, inside the run, and keeps the run's + * value. A null by which a change made text inside the run plain (typed + * plain text, a removal) is no such end: the undo of that change brings + * the value back after it. Likewise, where the run this write opens (its + * range's value, or inserted text's) ends at another client's change end + * that it reuses as its own closer, it writes a null of its own right + * after it (a link a collaborator undid while another linked the text + * up to its end ran on to the end of the document). */ format(n: number, formats: Record): void { const passed = new Set();