diff --git a/.changeset/lexical-inline-element-text-fields.md b/.changeset/lexical-inline-element-text-fields.md new file mode 100644 index 00000000000..c47abf0d40d --- /dev/null +++ b/.changeset/lexical-inline-element-text-fields.md @@ -0,0 +1,10 @@ +--- +'lexical': patch +'@lexical/clipboard': patch +--- + +[fix] Stop serializing the textFormat and textStyle of an inline element whose text sits in another element inside it + +An element persists its textFormat and textStyle only when it has no text to derive them from (#7968), and a reconcile derives them from its first text at any depth. The JSON of an inline element (a link, an autolink, a mark) now follows the same rule: a link whose text sits in a mark no longer writes them, as a link with text of its own already did not. JSON that holds them still reads the same, and a block keeps the direct-children check, so the JSON of a paragraph whose text is all inside links, and of every other node, is unchanged. + +Verify: JSON snapshots of an inline element (a `link`, `autolink` or `mark`, or your own inline `ElementNode`) whose children are all elements no longer hold `textFormat` or `textStyle`. diff --git a/.changeset/lexical-y-link-textformat.md b/.changeset/lexical-y-link-textformat.md new file mode 100644 index 00000000000..460d6f99bc3 --- /dev/null +++ b/.changeset/lexical-y-link-textformat.md @@ -0,0 +1,9 @@ +--- +'@lexical/y': patch +--- + +[fix] Keep a link a collaborator removes removed when another collaborator formats text covering it at the same time + +A format change over a link also sets the link's textFormat (`RangeSelection#formatText`), and the binding stored a link's textFormat and textStyle in the value its text carries: the format change wrote the link's whole value again over its text, and that write brought a concurrently removed link back for everyone. A wrapper's textFormat and textStyle are now stored only when Lexical serializes them (`shouldSerializeTextStyles`): while it holds no text, of its own or inside another element in it (a mark in a link). With text, they follow it, and the author's JSON and a fresh reader's agree. + +Verify: with two clients on a paragraph that holds a link, remove the link on one while the other makes a range covering the whole link code, sync, and check that the link stays removed on both. diff --git a/packages/lexical-clipboard/src/clipboard.ts b/packages/lexical-clipboard/src/clipboard.ts index 0d5ecce6d23..8035d47957a 100644 --- a/packages/lexical-clipboard/src/clipboard.ts +++ b/packages/lexical-clipboard/src/clipboard.ts @@ -53,6 +53,7 @@ import { getDOMSelection, getDOMSelectionPoints, getEditorPropertyFromDOMNode, + INTERNAL_$withExportMemo, isHTMLElement, isLexicalEditor, isSelectionWithinEditor, @@ -703,10 +704,12 @@ export function $generateJSONFromSelectedNodes< const topLevelChildren = ( $isElementNode(slotFrame) ? slotFrame : root ).getChildren(); - for (let i = 0; i < topLevelChildren.length; i++) { - const topLevelNode = topLevelChildren[i]; - $appendNodesToJSON(editor, selection, topLevelNode, nodes); - } + INTERNAL_$withExportMemo(() => { + for (let i = 0; i < topLevelChildren.length; i++) { + const topLevelNode = topLevelChildren[i]; + $appendNodesToJSON(editor, selection, topLevelNode, nodes); + } + }); return { namespace: editor._config.namespace, nodes, diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index cc48fe4076a..6732f68e306 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1205,9 +1205,11 @@ not merge as a user might expect: whether you saw it before you undo or it arrives as you undo: bold `one two`, let a collaborator type plain ` mid` after `one`, then undo the bold, and ` two` stays bold. 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 + while a collaborator formats text inside it removes the link on every + client: a format writes no link while the link holds text. A link with no + text (only an inline decorator or a line break) still stores its text + format, and stays on its content: + [#9627](https://github.com/meta/lexical/issues/9627). 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 @@ -1286,7 +1288,12 @@ not merge as a user might expect: after a Backspace it takes the type of the line before (a paragraph after a heading becomes a heading), after undoing a split it keeps its own. Bold ‖ bold on adjacent ranges of one line - can leave part unformatted. A format removed from a range while a + can leave part unformatted, and so can bold ‖ bold on overlapping ranges + ([#9626](https://github.com/meta/lexical/issues/9626)). A link behaves + like a comment there: a range that crosses a link's edge can lose part + of its bold for either client id order, and a bold reaching into a + link's first word can lose it there when a collaborator one update + behind types inside the old bold range. A format removed from a range while a collaborator types inside it without that format (unbolding a word as they type into it, resolving a comment as they paste into it) comes back on the part of the range after their text: their write restores the @@ -1327,9 +1334,6 @@ reproduces it: - An update listener that throws stops the listeners registered after it, so when it was registered before the binding, that commit is never written: [#9514](https://github.com/meta/lexical/issues/9514). -- Undoing a link while a collaborator formats text over the link's start - keeps the link on its own text (see Undo above): - [#9521](https://github.com/meta/lexical/issues/9521). - A line without a marker joins the start of a paragraph a collaborator appends at the same time: [#9524](https://github.com/meta/lexical/issues/9524). @@ -1339,11 +1343,6 @@ reproduces it: - A comment can spread onto later words when a client types uncommented text inside it offline while a collaborator's deletes there drop the item that ended it: [#9564](https://github.com/meta/lexical/issues/9564). -- A link a collaborator removes comes back when another collaborator - formats a range covering it at the same time; an update that makes a - line's text nodes again without the link while a stale edit formats a - range reaching past the link's text shows the same: - [#9597](https://github.com/meta/lexical/issues/9597). - Turning a paragraph inside a container that is itself inside a container (both not shadow roots) into a list removes the outer container and makes no list, in every `@lexical/y` editor (it runs diff --git a/packages/lexical-website/docs/serialization/serialization.md b/packages/lexical-website/docs/serialization/serialization.md index 6b9c9800901..c6509e04c99 100644 --- a/packages/lexical-website/docs/serialization/serialization.md +++ b/packages/lexical-website/docs/serialization/serialization.md @@ -250,8 +250,9 @@ off the common path, so an element with nothing to persist never calls it. The predicate must be pure and take no arguments: the walk calls it once per property that names it, while generated code hoists one that several properties share and calls it once. This is how `ElementNode` persists `textFormat` and -`textStyle` only for an element with no `TextNode` child, without either -property leaving the direct-field path. +`textStyle` only for an element with no text to derive them from (no +`TextNode` child, and for an inline element such as a link no `TextNode` +anywhere inside it), without either property leaving the direct-field path. `withField(schema, {field, when})` declares the same for a property that is the field in both directions. Either way the gate belongs to the export direction, diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index b4623f3433e..9d25493528f 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -64,6 +64,7 @@ issues and docs that carry it. | D30 | **Diffed rewrites snap to whole words**; keystrokes and IME composition keep the exact diff. Scripts without spaces treat each character as a boundary. **A diffed insertion or deletion that no caret places (none, or one at the end of the node, past the edit or in a later node) and that the diff would put inside a word goes to the first place in the run of equal text it could be in where both its ends are at word edges** (a word deleted with the separator before it); a caret's placement, and one already at word edges, stay. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). Without a caret a diff takes the common start first and put deletions inside words, so a collaborator's concurrent delete of the same word took a letter of the next one ([#9636]); the first edge keeps a word typed after a deleted word apart from the next one, as typing goes (a space, then a word). | `DECIDED` | 10-30 | [#9400], [#9467], [#9636] | | D31 | **A list item's nested list gets an item of its own.** `NodeStructureExtension`'s list rule prevents an item holding a nested list next to other content when it is written; the binding does not repair it afterwards. | That shape lost text in outdents, Backspaces and offline merges. Preventing it at write time follows D20. | `DECIDED` | 10-26 | [#9452], [y.md: Limits][limits] | | D32 | **List merging ignores `start`.** | Existing Lexical behaviour, intended. | `DECIDED` | 10-23 | [#9452] | +| D82 | **An inline element's textFormat and textStyle are persisted only while it holds no text at any depth, and the binding stores a wrapper's exactly when Lexical serializes them.** Lexical's `shouldSerializeTextStyles` looks through the elements inside an inline element (a mark in a link), as a reconcile already derives the two from its first text there; a block keeps the direct-children check. Exported JSON changes only for a link, autolink or mark whose children are all elements: those two fields are no longer written (they are still read, into the same state). | Stored while the wrapper holds text, a format change over a link writes the link's value again, and a collaborator's concurrent removal of the link is lost ([#9597]); left unstored while Lexical serializes them, the author's JSON differs from a fresh reader's. One rule, owned by core, keeps both, and the JSON of blocks and of every other node is unchanged (D01). A link with no text at all (only an inline decorator or a line break) still stores them and still comes back ([#9627]). While a wrapper holds text, its value without text fields does not remove ones stored there, so a link that gets text keeps a concurrent removal or new URL. A link whose stored value already holds them (written by an earlier build after a format over it) can come back once, until its next write. Accepted with it: a link behaves like a mark in overlapping same-format races ([#9626]), which now reach a link's edge for either client id order and a link's first word; formatting only, no text lost. | `DECIDED` | 10-30 | [#9601] | | 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] | | D72 | **The binding starts at one fixed point, with no further deferral or special-casing.** It starts in `YCollabExtension`'s `afterRegistration`, once every extension has registered and the initial state is applied. An initialization-order bug (a table that exists before its DOM) is fixed in the extension that has it, as [#9501] did. | Deferring the first read further changes when the binding's state exists and still misses code that registers later (legacy table observers, React plugins). | `DECIDED` | 10-28 | [#9501], [#9537] | | D73 | **`@lexical/y` first publishes with its whole launch stack** (the binding, the server API, the playground and per-member sets), with real moves after (D27). The launch stack was chosen on 10-23; moves were moved after it on 10-25. | The first published format should already include what the launch needs, such as per-member sets. | `DECIDED` | 10-25 | [#9400], [#9437], [#9438], [#9439] | @@ -194,6 +195,7 @@ issues and docs that carry it. [#9593]: https://github.com/meta/lexical/issues/9593 [#9598]: https://github.com/meta/lexical/pull/9598 [#9599]: https://github.com/meta/lexical/issues/9599 +[#9601]: https://github.com/meta/lexical/pull/9601 [#9603]: https://github.com/meta/lexical/issues/9603 [#9605]: https://github.com/meta/lexical/pull/9605 [#9607]: https://github.com/meta/lexical/pull/9607 @@ -201,6 +203,9 @@ issues and docs that carry it. [#9609]: https://github.com/meta/lexical/issues/9609 [#9610]: https://github.com/meta/lexical/issues/9610 [#9621]: https://github.com/meta/lexical/issues/9621 +[#9626]: https://github.com/meta/lexical/issues/9626 +[#9627]: https://github.com/meta/lexical/issues/9627 [#9635]: https://github.com/meta/lexical/issues/9635 [#9636]: https://github.com/meta/lexical/issues/9636 +[#9597]: https://github.com/meta/lexical/issues/9597 [#9606]: https://github.com/meta/lexical/issues/9606 diff --git a/packages/lexical-y/src/__tests__/unit/LinkRemovedWhileFormatted.test.ts b/packages/lexical-y/src/__tests__/unit/LinkRemovedWhileFormatted.test.ts new file mode 100644 index 00000000000..4e997271692 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/LinkRemovedWhileFormatted.test.ts @@ -0,0 +1,135 @@ +/** + * 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 + +// A removes a link while B, at the same time, formats a range covering the +// whole link (code). B's format sets the link's textFormat too +// (RangeSelection#formatText); with the link's textFormat stored in its +// value, B wrote the link's value again over its text, and that write +// brought the removed link back for everyone. The link stays removed. + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $createLinkNode, + $isLinkNode, + $toggleLink, + LinkExtension, +} from '@lexical/link'; +import {RichTextExtension} from '@lexical/rich-text'; +import * as Y from '@y/y'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $isElementNode, + $setSelection, + configExtension, + defineExtension, + type LexicalEditor, + type TextNode, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import {YCollabExtension} from '../..'; +import {createYDoc, seedYState} from '../../headless'; + +const extensions = [RichTextExtension, LinkExtension]; +const config = {extensions, onWarn: () => {}, schemaVersion: 1}; + +// "t28z " + link(https://c.test/100: "t29z t30z") + " t31z" +function $build() { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('t28z '), + $createLinkNode('https://c.test/100').append( + $createTextNode('t29z t30z'), + ), + $createTextNode(' t31z'), + ), + ); +} + +function client(seed: Uint8Array, id: number) { + const doc = createYDoc(seed); + doc.clientID = id; + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + dependencies: [ + ...extensions, + configExtension(YCollabExtension, {doc, schemaVersion: 1}), + ], + name: 'client' + id, + }), + ); + return {doc, editor}; +} + +function $select(from: string, to: string) { + const texts = $getRoot().getAllTextNodes(); + const find = (word: string, end: boolean): [TextNode, number] => { + for (const t of texts) { + const i = t.getTextContent().indexOf(word); + if (i >= 0) return [t, end ? i + word.length : i]; + } + throw new Error(word); + }; + const [a, ao] = find(from, false); + const [b, bo] = find(to, true); + const s = $createRangeSelection(); + s.anchor.set(a.getKey(), ao, 'text'); + s.focus.set(b.getKey(), bo, 'text'); + $setSelection(s); + return s; +} + +const links = (editor: LexicalEditor) => + editor.getEditorState().read(() => + $getRoot() + .getChildren() + .flatMap(p => ($isElementNode(p) ? p.getChildren() : [])) + .filter($isLinkNode) + .map(l => l.getTextContent()), + ); + +for (const ids of [ + [1, 2], + [2, 1], +]) { + test(`a removed link stays removed when a collaborator formats over it at the same time (ids ${ids})`, async () => { + const seed = seedYState(config, $build); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + // A removes the link; B, at the same time, makes "t28z ... t30z" code + a.editor.update( + () => { + $select('t29z', 't30z'); + $toggleLink(null); + }, + {discrete: true}, + ); + b.editor.update( + () => { + $select('t28z', 't30z').formatText('code'); + }, + {discrete: true}, + ); + const ua = Y.encodeStateAsUpdate(a.doc); + const ub = Y.encodeStateAsUpdate(b.doc); + Y.applyUpdate(a.doc, ub); + Y.applyUpdate(b.doc, ua); + await Promise.resolve(); + expect(links(a.editor)).toEqual([]); + expect(links(b.editor)).toEqual([]); + }); +} diff --git a/packages/lexical-y/src/__tests__/unit/LinkWithMarkRemovedWhileFormatted.test.ts b/packages/lexical-y/src/__tests__/unit/LinkWithMarkRemovedWhileFormatted.test.ts new file mode 100644 index 00000000000..19169527a37 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/LinkWithMarkRemovedWhileFormatted.test.ts @@ -0,0 +1,144 @@ +/** + * 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 + +// As LinkRemovedWhileFormatted, with the link's text inside a mark: the +// link holds its text through an inline element, and B's format still +// writes no link value, so A's removal of the link stays. + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $createLinkNode, + $isLinkNode, + $toggleLink, + LinkExtension, +} from '@lexical/link'; +import {$createMarkNode, MarkExtension} from '@lexical/mark'; +import {RichTextExtension} from '@lexical/rich-text'; +import * as Y from '@y/y'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $isElementNode, + $setSelection, + configExtension, + defineExtension, + type LexicalEditor, + type TextNode, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import {YCollabExtension} from '../..'; +import {createYDoc, seedYState} from '../../headless'; + +const extensions = [RichTextExtension, LinkExtension, MarkExtension]; +const config = {extensions, schemaVersion: 1}; + +function $build(): void { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('before '), + $createLinkNode('https://nested.test').append( + $createMarkNode(['c1']).append($createTextNode('linked words')), + ), + $createTextNode(' after'), + ), + ); +} + +function client(seed: Uint8Array, id: number) { + const doc = createYDoc(seed); + doc.clientID = id; + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + dependencies: [ + ...extensions, + configExtension(YCollabExtension, {doc, schemaVersion: 1}), + ], + name: `nested-${id}`, + }), + ); + return {doc, editor}; +} + +function $select(from: string, to: string) { + const find = (word: string, end: boolean): [TextNode, number] => { + for (const text of $getRoot().getAllTextNodes() as TextNode[]) { + const offset = text.getTextContent().indexOf(word); + if (offset >= 0) { + return [text, end ? offset + word.length : offset]; + } + } + throw new Error(`missing ${word}`); + }; + const [a, ao] = find(from, false); + const [b, bo] = find(to, true); + const selection = $createRangeSelection(); + selection.anchor.set(a.getKey(), ao, 'text'); + selection.focus.set(b.getKey(), bo, 'text'); + $setSelection(selection); + return selection; +} + +function links(editor: LexicalEditor): string[] { + return editor.read(() => { + const out: string[] = []; + const visit = (node: ReturnType) => { + for (const child of node.getChildren()) { + if ($isLinkNode(child)) { + out.push(child.getTextContent()); + } + if ($isElementNode(child)) { + visit(child as ReturnType); + } + } + }; + visit($getRoot()); + return out; + }); +} + +for (const ids of [ + [1, 2], + [2, 1], +]) { + test(`a removed link containing a mark stays removed when formatted (ids ${ids})`, async () => { + const seed = seedYState(config, $build); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + a.editor.update( + () => { + $select('linked', 'words'); + $toggleLink(null); + }, + {discrete: true}, + ); + expect(links(a.editor)).toEqual([]); + b.editor.update( + () => { + $select('before', 'words').formatText('code'); + }, + {discrete: true}, + ); + expect(links(b.editor)).toEqual(['linked words']); + const ua = Y.encodeStateAsUpdate(a.doc); + const ub = Y.encodeStateAsUpdate(b.doc); + Y.applyUpdate(a.doc, ub); + Y.applyUpdate(b.doc, ua); + await Promise.resolve(); + expect(links(a.editor)).toEqual([]); + expect(links(b.editor)).toEqual([]); + }); +} diff --git a/packages/lexical-y/src/__tests__/unit/regressions/BoldAcrossLinkEnd.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/BoldAcrossLinkEnd.test.ts new file mode 100644 index 00000000000..7dda9a611d6 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/BoldAcrossLinkEnd.test.ts @@ -0,0 +1,95 @@ +/** + * 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 + +// Two collaborators bold overlapping ranges at the same time: A bolds +// t4z..t7z while B bolds t3z..t6z. t7z should end bold: A bolded it and +// nobody else touched it. Two items of one format key at one spot resolve by +// client id, and part of A's range ends plain (#9626), without a link for +// both client id orders. With A first linking t5z t6z (A's bold then crosses +// the link's end), main kept t7z bold for client ids 2,1 only because A's +// bold also wrote the link's value again; a link's text fields are no longer +// stored (#9597), so the link behaves like a mark there and t7z ends plain +// for both orders: expected failures naming #9626. + +import {$toggleLink} from '@lexical/link'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, + type TextNode, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import {$selectIn, expectConverged, setup} from '../../utils/harness'; + +const LINE = 't1z t2z t3z t4z t5z t6z t7z t8z'; +const at = (word: string) => LINE.indexOf(word); + +for (const ids of [ + [1, 2], + [2, 1], +]) { + for (const [name, aLinksFirst, bBolds] of [ + ['A links t5z t6z, then bolds t4z..t7z || B bolds t3z..t6z', true, true], + ['A bolds t4z..t7z || B bolds t3z..t6z (no link)', false, true], + ['control: A links t5z t6z, then bolds t4z..t7z', true, false], + ] as [string, boolean, boolean][]) { + // #9626: overlapping bolds of two collaborators leave part plain. + const known = bBolds; + (known ? test.fails : test)( + `${name}, ids ${ids}: t7z keeps A's bold${known ? ' (#9626)' : ''}`, + async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode(LINE)), + ); + }, + {ids}, + ); + try { + t.offline(); + if (aLinksFirst) { + t.a.update(() => { + $selectIn([0], at('t5z'), at('t6z') + 3); + $toggleLink('https://x.test/12'); + $setSelection(null); + }); + } + t.a.update(() => { + $selectIn([0], at('t4z'), at('t7z') + 3).formatText('bold'); + $setSelection(null); + }); + if (bBolds) { + t.b.update(() => { + $selectIn([0], at('t3z'), at('t6z') + 3).formatText('bold'); + $setSelection(null); + }); + } + await t.sync(); + await expectConverged(t.peers); + expect( + t.a.read(() => + ( + $getRoot() + .getAllTextNodes() + .find(n => n.getTextContent().includes('t7z')) as TextNode + ).hasFormat('bold'), + ), + ).toBe(true); + } finally { + for (const p of t.peers) { + p.stop(); + } + } + }, + ); + } +} diff --git a/packages/lexical-y/src/__tests__/unit/regressions/BoldIntoLinkBehind.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/BoldIntoLinkBehind.test.ts new file mode 100644 index 00000000000..4a00477e432 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/BoldIntoLinkBehind.test.ts @@ -0,0 +1,137 @@ +/** + * 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 + +// A bolds t5z t6z, links t7z..t10z and underlines t6z, and B gets those. +// Then, at the same time, A bolds "t6z t7z" (the bold now reaches into the +// link's first word) while B, one update behind, types after t5z inside the +// old bold run. B's write closes that run again at its old end, in the same +// spot where A's write extends it: two items of one format key at one spot +// (#9626). For client ids 1,2, B's closer wins, and A's bold ends after t6z. +// The space after t6z loses it on main too. t7z keeps it on main only because +// A's bold over the link also wrote the link's value again and opened bold +// inside it. With a link's text fields no longer stored (#9597), the link +// behaves like a mark there (a comment on t7z loses it on main the same +// way), so t7z loses it too: expected failures naming #9626. + +import {$toggleLink} from '@lexical/link'; +import * as Y from '@y/y'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, + type TextNode, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import {$selectIn, expectConverged, setup} from '../../utils/harness'; + +// "t1z t2z t3z t4z t5z t6z t7z t8z t9z t10z" +const $range = (from: string, to: string) => { + const text = $getRoot().getTextContent().replace(/\n/g, ''); + return [text.indexOf(from), text.indexOf(to) + to.length]; +}; + +for (const ids of [ + [1, 2], + [2, 1], +]) { + for (const [name, bTypes, bUnstrike] of [ + ['B types after t5z', true, false], + ['B types after t5z, after a no-op unstrike', true, true], + ['control: B does nothing', false, false], + ] as [string, boolean, boolean][]) { + // #9626: B's closer at the old end of the bold run wins over A's + // extension of it for this order. + const known = bTypes && ids[0] === 1; + (known ? test.fails : test)( + `${name}, ids ${ids}: t7z keeps A's bold${known ? ' (#9626)' : ''}`, + async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('t1z t2z t3z t4z t5z t6z t7z t8z t9z t10z'), + ), + ); + }, + {ids}, + ); + const $select = (from: string, to: string) => { + const [s, e] = $range(from, to); + return $selectIn([0], s, e); + }; + try { + t.offline(); + if (bUnstrike) { + t.b.update(() => { + const selection = $select('t8z', 't8z'); + selection.format = 4; + selection.formatText('strikethrough'); + $setSelection(null); + }); + } + t.a.update(() => { + $select('t5z', 't6z').formatText('bold'); + $setSelection(null); + }); + t.a.update(() => { + $select('t7z', 't10z'); + $toggleLink('https://x.test/01'); + $setSelection(null); + }); + t.a.update(() => { + $select('t6z', 't6z').formatText('underline'); + $setSelection(null); + }); + t.a.update(() => { + $select('t6z', 't7z').formatText('bold'); + $setSelection(null); + }); + // B gets A's first three updates, not the fourth. + const pending = ( + t.b as unknown as {pending: Map} + ).pending.get(t.a)!; + for (const update of pending.splice(0, 3)) { + Y.applyUpdate( + t.b.doc, + update, + (t.b as unknown as {net: unknown}).net, + ); + } + await new Promise(resolve => setTimeout(resolve, 0)); + if (bTypes) { + t.b.update(() => { + const [, end] = $range('t5z', 't5z'); + const selection = $selectIn([0], end, end); + selection.format = 0; + selection.insertText(' q12z'); + $setSelection(null); + }); + } + await t.sync(); + await expectConverged(t.peers); + expect( + t.a.read(() => + ( + $getRoot() + .getAllTextNodes() + .find(n => n.getTextContent().includes('t7z')) as TextNode + ).hasFormat('bold'), + ), + ).toBe(true); + } finally { + for (const p of t.peers) { + p.stop(); + } + } + }, + ); + } +} 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 c2a94e1efa3..0d1dfbc8b9b 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts @@ -1697,8 +1697,10 @@ describe('a value one client restates ‖ the undo of the change that gave it', ); }); // 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. + // also sets the link's textFormat (RangeSelection#formatText). That + // write restated the link over its text and kept it there; a link's + // textFormat is no longer stored while it has text (#9597), so the + // format change writes no link and B's undo removes it on every peer. 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( @@ -1717,7 +1719,7 @@ describe('a value one client restates ‖ the undo of the change that gave it', 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 / '); + expect(p.read($links)).toBe(' / '); } }); } diff --git a/packages/lexical-y/src/__tests__/unit/regressions/LinkRetargetedWhileFormatted.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/LinkRetargetedWhileFormatted.test.ts new file mode 100644 index 00000000000..78df2d8bca2 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/LinkRetargetedWhileFormatted.test.ts @@ -0,0 +1,117 @@ +/** + * 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 + +// A links t4z, and B gets it. Then, at the same time, A extends that link +// over t4z..t6z with a new URL (after linking t2z..t3z, or not), while B +// underlines text covering the link or inside it. Every character of t4z, +// t5z and t6z shows A's new link: B's format writes no value for the link +// (#9597, whose fix this shares), so the old URL does not come back over t4z +// and the extension is not cut. Compared per character (a word can span +// text nodes). + +import {$toggleLink, LinkNode} from '@lexical/link'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import {$selectIn, expectConverged, setup} from '../../utils/harness'; + +// "t1z t2z t3z t4z t5z t6z": t2z 4..7 t3z 8..11 t4z 12..15 t5z 16..19 t6z 20..23 +for (const ids of [ + [1, 2], + [2, 1], +]) { + for (const [name, withB, with02, bs, be] of [ + ['A 02 + 03 || B underlines t3z-t4z', true, true, 8, 15], + ['A 03 only || B underlines t3z-t4z', true, false, 8, 15], + [ + 'A 03 only || B underlines t4z (the link text exactly)', + true, + false, + 12, + 15, + ], + ['A 03 only || B underlines "4z" (inside the link)', true, false, 13, 15], + [ + 'A 03 only || B underlines t2z-t3z (control: not the link)', + true, + false, + 4, + 11, + ], + ['control: A 02 + 03, B does nothing', false, true, 0, 0], + ] as [string, boolean, boolean, number, number][]) { + test(`${name} (ids ${ids})`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('t1z t2z t3z t4z t5z t6z'), + ), + ); + }, + {ids}, + ); + t.a.update(() => { + $selectIn([0], 12, 15); + $toggleLink('https://x.test/01'); + $setSelection(null); + }); + await t.sync(); + t.offline(); + if (with02) { + t.a.update(() => { + $selectIn([0], 4, 11); + $toggleLink('https://x.test/02'); + $setSelection(null); + }); + } + t.a.update(() => { + $selectIn([0], 12, 23); + $toggleLink('https://x.test/03'); + $setSelection(null); + }); + if (withB) { + t.b.update(() => { + $selectIn([0], bs, be).formatText('underline'); + $setSelection(null); + }); + } + await t.sync(); + await expectConverged(t.peers); + // per character link URL, then per word (a word can span text nodes) + const urls = t.a.read(() => { + let text = ''; + const per: string[] = []; + for (const n of $getRoot().getAllTextNodes()) { + const p = n.getParent(); + for (const ch of n.getTextContent()) { + text += ch; + per.push(p instanceof LinkNode ? p.getURL().slice(-2) : '-'); + } + } + const out: Record = {}; + for (const m of text.matchAll(/t\d+z/g)) + out[m[0]] = [ + ...new Set(per.slice(m.index!, m.index! + m[0].length)), + ].join('|'); + return out; + }); + expect({t4z: urls.t4z, t5z: urls.t5z, t6z: urls.t6z}).toEqual({ + t4z: '03', + t5z: '03', + t6z: '03', + }); + }); + } +} diff --git a/packages/lexical-y/src/__tests__/unit/regressions/RemadeTextStaleLink.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/RemadeTextStaleLink.test.ts index 55fc4b01f6b..487c632cdcf 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/RemadeTextStaleLink.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/RemadeTextStaleLink.test.ts @@ -13,11 +13,9 @@ // while a stale edit computed before the rewrite formats a range of it. The // rewrite keeps the unchanged words between the changes as the same // characters (#9621), as it keeps a line's unchanged start or end with one -// changed word (#9598), so a stale format whose range reaches past the link -// restates the link on them: #9597, the mechanism by which a link a -// collaborator removes comes back when another formats a range covering it -// at the same time. Those cells are expected failures until the fix for -// #9597 (#9601) lands. +// changed word (#9598). A stale format whose range reaches past the link +// does not restate the link on them (#9597: it writes no value for the +// link), so the removed link stays removed. import {$createLinkNode, $isLinkNode, LinkExtension} from '@lexical/link'; import {RichTextExtension} from '@lexical/rich-text'; @@ -101,58 +99,50 @@ for (const link of [['t25z', 't26z'], ['t24z', 't25z'], ['t25z']]) { ['t25z', 't25z'], ['t23z', 't26z'], ]) { - // The stale format reaches past the link (#9597). - const reachesPast = WORDS.slice( - WORDS.indexOf(range[0]), - WORDS.indexOf(range[1]) + 1, - ).some(w => !link.includes(w)); for (const order of ['rewrite first', 'format first']) { - (reachesPast ? test.fails : test)( - `link on ${link.join(' ')}, a stale ${format} of ${range.join('..')}, ${order}: the removed link stays removed${reachesPast ? ' (#9597, fixed by #9601)' : ''}`, - () => { - const s0 = seedYState(config, () => { - const p = $createParagraphNode(); - const i = WORDS.indexOf(link[0]); - const j = WORDS.indexOf(link[link.length - 1]); - if (i > 0) { - p.append($createTextNode(WORDS.slice(0, i).join(' ') + ' ')); - } - p.append( - $createLinkNode('https://example.com').append( - $createTextNode(link.join(' ')), - ), - ); - if (j < WORDS.length - 1) { - p.append($createTextNode(' ' + WORDS.slice(j + 1).join(' '))); - } - $getRoot().append(p); - }); - const rewrite = edit(s0, 'rewrite', () => { - const block = $getRoot().getFirstChildOrThrow() as ElementNode; - const next = block - .getTextContent() - .replace('t23z', 't66z') - .replace('t27z', 't68z'); - for (const child of block.getChildren()) { - child.remove(); - } - block.append($createTextNode(next)); - }); - const stale = edit(s0, 'stale', () => { - $selectWords(range[0], range[1]).formatText(format); - }); - const doc = createYDoc(s0); - for (const u of order === 'rewrite first' - ? [rewrite, stale] - : [stale, rewrite]) { - Y.applyUpdate(doc, u); + test(`link on ${link.join(' ')}, a stale ${format} of ${range.join('..')}, ${order}: the removed link stays removed`, () => { + const s0 = seedYState(config, () => { + const p = $createParagraphNode(); + const i = WORDS.indexOf(link[0]); + const j = WORDS.indexOf(link[link.length - 1]); + if (i > 0) { + p.append($createTextNode(WORDS.slice(0, i).join(' ') + ' ')); } - const alone = createYDoc(s0); - Y.applyUpdate(alone, rewrite); - expect(linkedWords(Y.encodeStateAsUpdate(alone))).toEqual([]); - expect(linkedWords(Y.encodeStateAsUpdate(doc))).toEqual([]); - }, - ); + p.append( + $createLinkNode('https://example.com').append( + $createTextNode(link.join(' ')), + ), + ); + if (j < WORDS.length - 1) { + p.append($createTextNode(' ' + WORDS.slice(j + 1).join(' '))); + } + $getRoot().append(p); + }); + const rewrite = edit(s0, 'rewrite', () => { + const block = $getRoot().getFirstChildOrThrow() as ElementNode; + const next = block + .getTextContent() + .replace('t23z', 't66z') + .replace('t27z', 't68z'); + for (const child of block.getChildren()) { + child.remove(); + } + block.append($createTextNode(next)); + }); + const stale = edit(s0, 'stale', () => { + $selectWords(range[0], range[1]).formatText(format); + }); + const doc = createYDoc(s0); + for (const u of order === 'rewrite first' + ? [rewrite, stale] + : [stale, rewrite]) { + Y.applyUpdate(doc, u); + } + const alone = createYDoc(s0); + Y.applyUpdate(alone, rewrite); + expect(linkedWords(Y.encodeStateAsUpdate(alone))).toEqual([]); + expect(linkedWords(Y.encodeStateAsUpdate(doc))).toEqual([]); + }); } } } diff --git a/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFields.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFields.test.ts new file mode 100644 index 00000000000..2108d21fc2d --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFields.test.ts @@ -0,0 +1,750 @@ +/** + * 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 + +// The textFormat and textStyle of an inline element the binding stores as a +// wrapper (link, autolink, mark, overflow), over every nesting of them: the +// catalog's inline elements one to two deep in every order (three with +// MATRIX_FULL=1), around plain, formatted and styled text, two texts, a line +// break, text next to a nested wrapper or an inline decorator, an inline +// decorator alone and a line break alone, with and without text fields of +// their own. +// +// - An author's JSON equals a fresh reader's after a format or style change +// over the paragraph or over the wrapped text alone (#9597's fix stores +// them only where Lexical serializes them; for a wrapper whose text sits +// in another wrapper, a mark in a link, Lexical serializes them only +// since this fix, so the two agree). +// - A wrapper one client removes stays removed while another formats a range +// covering it, in both client id orders (#9597: the format wrote the +// wrapper's value again and brought it back). + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $createAutoLinkNode, + $createLinkNode, + $isAutoLinkNode, + $isLinkNode, +} from '@lexical/link'; +import {$createMarkNode, $isMarkNode} from '@lexical/mark'; +import {$createOverflowNode, $isOverflowNode} from '@lexical/overflow'; +import {$patchStyleText} from '@lexical/selection'; +import * as Y from '@y/y'; +import { + $createLineBreakNode, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getEditor, + $getRoot, + $isElementNode, + $setSelection, + $setState, + configExtension, + createState, + defineExtension, + type ElementNode, + type LexicalEditor, + type LexicalNode, + type SerializedEditorState, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import {$createEquationNode} from '../../../../../lexical-playground/src/nodes/EquationNode'; +import {YCollabExtension} from '../../..'; +import {createYDoc, readYState, seedYState} from '../../../headless'; +import {catalogAppExtension, catalogHeadlessConfig} from '../../catalog'; + +const config = catalogHeadlessConfig(); + +type Kind = 'link' | 'autolink' | 'mark' | 'mark2' | 'overflow'; +const KINDS: readonly Kind[] = [ + 'link', + 'autolink', + 'mark', + 'mark2', + 'overflow', +]; + +function $create(kind: Kind): ElementNode { + switch (kind) { + case 'link': + return $createLinkNode('https://wrapped.test'); + case 'autolink': + return $createAutoLinkNode('https://auto.test'); + case 'mark': + return $createMarkNode(['c1']); + case 'mark2': + return $createMarkNode(['c2']); + case 'overflow': + return $createOverflowNode(); + } +} + +/** Whether `node` is the wrapper `kind` makes. */ +function $is(kind: Kind, node: LexicalNode): boolean { + switch (kind) { + case 'link': + return $isLinkNode(node) && !$isAutoLinkNode(node); + case 'autolink': + return $isAutoLinkNode(node); + case 'mark': + case 'mark2': + return ( + $isMarkNode(node) && + node.getIDs().includes(kind === 'mark' ? 'c1' : 'c2') + ); + case 'overflow': + return $isOverflowNode(node); + } +} + +// MATRIX_FULL=1 nests three deep; the default run two (every pair, in both +// orders). +const DEPTH = process.env.MATRIX_FULL === '1' ? 3 : 2; + +/** Every chain of wrappers, outermost first, one to DEPTH deep. */ +function chains(): Kind[][] { + const out: Kind[][] = []; + const grow = (chain: Kind[]) => { + if (chain.length > 0) { + out.push(chain); + } + if (chain.length === DEPTH) { + return; + } + for (const k of KINDS) { + const links = [...chain, k].filter(c => c === 'link' || c === 'autolink'); + if (!chain.includes(k) && links.length <= 1) { + grow([...chain, k]); + } + } + }; + grow([]); + return out; +} + +type Leaf = + | 'text' + | 'bold' + | 'styled' + | 'two-texts' + | 'line-break' + | 'text+wrapper' + | 'text+decorator' + | 'decorator' + | 'no-text'; +const LEAVES: readonly Leaf[] = [ + 'text', + 'bold', + 'styled', + 'two-texts', + 'line-break', + 'text+wrapper', + 'text+decorator', + 'decorator', + 'no-text', +]; + +function $leaf(leaf: Leaf): LexicalNode[] { + switch (leaf) { + case 'text': + return [$createTextNode('wrapped words')]; + case 'bold': + return [$createTextNode('wrapped words').setFormat('bold')]; + case 'styled': + return [$createTextNode('wrapped words').setStyle('color: red')]; + case 'two-texts': + return [ + $createTextNode('wrapped '), + $createTextNode('words').setFormat('italic'), + ]; + case 'line-break': + return [ + $createTextNode('wrapped'), + $createLineBreakNode(), + $createTextNode('words'), + ]; + case 'text+wrapper': + return [ + $createTextNode('wrapped '), + $createMarkNode(['c9']).append($createTextNode('words')), + ]; + case 'text+decorator': + return [$createTextNode('wrapped '), $createEquationNode('x^2', true)]; + case 'decorator': + return [$createEquationNode('x^2', true)]; + case 'no-text': + return [$createLineBreakNode()]; + } +} + +type Fields = 'none' | 'own'; + +function $build(chain: readonly Kind[], leaf: Leaf, fields: Fields): void { + let inner: LexicalNode[] = $leaf(leaf); + for (let i = chain.length - 1; i >= 0; i--) { + const wrapper = $create(chain[i]).append(...inner); + if (fields === 'own') { + wrapper.setTextFormat(2).setTextStyle('font-size: 12px'); + } + inner = [wrapper]; + } + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('before '), + ...inner, + $createTextNode(' after'), + ), + ); +} + +function client(seed: Uint8Array, id: number) { + const doc = createYDoc(seed); + doc.clientID = id; + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + dependencies: [ + catalogAppExtension(), + configExtension(YCollabExtension, {doc, schemaVersion: 1}), + ], + name: `wrapper-text-fields-${id}`, + }), + ); + return {doc, editor}; +} + +function sync(a: {doc: Y.Doc}, b: {doc: Y.Doc}): void { + const ua = Y.encodeStateAsUpdate(a.doc); + const ub = Y.encodeStateAsUpdate(b.doc); + Y.applyUpdate(a.doc, ub); + Y.applyUpdate(b.doc, ua); +} + +function $json(): SerializedEditorState { + return $getEditor().getEditorState().toJSON(); +} + +function json(editor: LexicalEditor): SerializedEditorState { + return editor.read($json); +} + +function fresh(doc: Y.Doc): SerializedEditorState { + return readYState(Y.encodeStateAsUpdate(doc), config, $json); +} + +/** The text nodes inside the outermost wrapper. */ +function $wrappedTexts(): TextNode[] { + const paragraph = $getRoot().getFirstChildOrThrow() as ElementNode; + const out: TextNode[] = []; + for (const child of paragraph.getChildren()) { + if ($isElementNode(child)) { + out.push(...child.getAllTextNodes()); + } + } + return out; +} + +function $selectAll(): ReturnType { + const texts = $getRoot().getAllTextNodes(); + const first = texts[0]; + const last = texts[texts.length - 1]; + const selection = $createRangeSelection(); + selection.anchor.set(first.getKey(), 0, 'text'); + selection.focus.set(last.getKey(), last.getTextContentSize(), 'text'); + $setSelection(selection); + return selection; +} + +function $selectWrapped(): ReturnType | null { + const texts = $wrappedTexts(); + if (texts.length === 0) { + return null; + } + const first = texts[0]; + const last = texts[texts.length - 1]; + const selection = $createRangeSelection(); + selection.anchor.set(first.getKey(), 0, 'text'); + selection.focus.set(last.getKey(), last.getTextContentSize(), 'text'); + $setSelection(selection); + return selection; +} + +type Op = 'format-all' | 'format-wrapped' | 'style-all' | 'style-wrapped'; +const OPS: readonly Op[] = [ + 'format-all', + 'format-wrapped', + 'style-all', + 'style-wrapped', +]; + +/** Runs `op`; false when it has nothing to act on. */ +function $op(op: Op): boolean { + const selection = + op === 'format-all' || op === 'style-all' ? $selectAll() : $selectWrapped(); + if (selection === null) { + return false; + } + if (op === 'format-all' || op === 'format-wrapped') { + selection.formatText('code'); + } else { + $patchStyleText(selection, {color: 'blue'}); + } + return true; +} + +/** Moves the children of the wrapper `kind` out of it and removes it. */ +function $unwrap(kind: Kind): void { + const visit = (node: ElementNode) => { + for (const child of node.getChildren()) { + if ($isElementNode(child)) { + if ($is(kind, child)) { + for (const grandchild of child.getChildren()) { + child.insertBefore(grandchild); + } + child.remove(); + } else { + visit(child); + } + } + } + }; + visit($getRoot()); +} + +function count(editor: LexicalEditor, kind: Kind): number { + return editor.read(() => { + let n = 0; + const visit = (node: ElementNode) => { + for (const child of node.getChildren()) { + if ($isElementNode(child)) { + if ($is(kind, child)) { + n++; + } + visit(child); + } + } + }; + visit($getRoot()); + return n; + }); +} + +/** The URLs of the wrappers `kind` (a link or an autolink). */ +function urls(editor: LexicalEditor, kind: Kind): string[] { + return editor.read(() => { + const out: string[] = []; + const visit = (node: ElementNode) => { + for (const child of node.getChildren()) { + if ($isElementNode(child)) { + if ($is(kind, child) && $isLinkNode(child)) { + out.push(child.getURL()); + } + visit(child); + } + } + }; + visit($getRoot()); + return out; + }); +} + +const CHAINS = chains(); + +type Shape = {chain: Kind[]; leaf: Leaf; fields: Fields; label: string}; +const SHAPES: Shape[] = []; +for (const chain of CHAINS) { + for (const leaf of LEAVES) { + for (const fields of ['none', 'own'] as const) { + SHAPES.push({ + chain, + fields, + label: `${chain.join('>')}>${leaf}${fields === 'own' ? ' own' : ''}`, + leaf, + }); + } + } +} + +describe('an author and a fresh reader have the same JSON', () => { + test.each(SHAPES)('$label', async ({chain, leaf, fields}) => { + const seed = seedYState(config, () => $build(chain, leaf, fields)); + const author = client(seed, 101); + const peer = client(seed, 102); + await Promise.resolve(); + expect(json(author.editor)).toEqual(fresh(author.doc)); + for (const op of OPS) { + let acted = false; + author.editor.update( + () => { + acted = $op(op); + }, + {discrete: true}, + ); + if (!acted) { + continue; + } + sync(author, peer); + await Promise.resolve(); + const expected = fresh(author.doc); + expect(json(author.editor), `${op}: author`).toEqual(expected); + expect(json(peer.editor), `${op}: peer`).toEqual(expected); + } + }); +}); + +// A client deletes the wrapped text but not a line break or an inline +// decorator next to it, after (or while) a client formats over the wrapper +// (the other client, or the same one): the wrapper now holds no text, so +// Lexical serializes its text fields, and every client shows the stored ones +// (the formatting client's own, set while the wrapper held text, were not +// stored). +describe('a wrapper whose text a client deletes after a format over it', () => { + const cells = SHAPES.filter( + ({leaf}) => leaf === 'line-break' || leaf === 'text+decorator', + ).flatMap(shape => + (['after', 'concurrent'] as const).flatMap(when => + (['peer', 'self'] as const).flatMap(who => + ( + [ + [1, 2], + [2, 1], + ] as [number, number][] + ).map(ids => ({...shape, ids, when, who})), + ), + ), + ); + test.each(cells)( + '$label: $who deletes $when, ids $ids.0,$ids.1', + async ({chain, leaf, fields, ids, when, who}) => { + const seed = seedYState(config, () => $build(chain, leaf, fields)); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + b.editor.update( + () => { + $selectAll().formatText('bold'); + }, + {discrete: true}, + ); + if (when === 'after') { + sync(a, b); + await Promise.resolve(); + } + (who === 'peer' ? a : b).editor.update( + () => { + for (const text of $wrappedTexts()) { + text.remove(); + } + $setSelection(null); + }, + {discrete: true}, + ); + await Promise.resolve(); + sync(a, b); + await Promise.resolve(); + const expected = fresh(a.doc); + expect(json(a.editor), 'a = fresh').toEqual(expected); + expect(json(b.editor), 'b = fresh').toEqual(expected); + }, + ); +}); + +// A client that formatted over a wrapper (synced) deletes the wrapper's text +// but not a line break or an inline decorator next to it, while the other +// client removes the wrapper, or for a link gives it another URL: the +// removal or the new URL stays on every client (the delete leaves the +// wrapper without text, and its text fields, set by the format while it +// held text and never stored, must not write its value again). +describe('a wrapper one client removes or retargets while the other deletes its text', () => { + const cells = SHAPES.filter( + ({leaf}) => leaf === 'line-break' || leaf === 'text+decorator', + ).flatMap(shape => + shape.chain.flatMap(target => + (target === 'link' || target === 'autolink' + ? (['remove', 'retarget'] as const) + : (['remove'] as const) + ).flatMap(change => + ( + [ + [1, 2], + [2, 1], + ] as [number, number][] + ).map(ids => ({...shape, change, ids, target})), + ), + ), + ); + test.each(cells)( + '$label: $change $target, ids $ids.0,$ids.1', + async ({chain, leaf, fields, change, ids, target}) => { + const seed = seedYState(config, () => $build(chain, leaf, fields)); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + b.editor.update( + () => { + $selectAll().formatText('bold'); + }, + {discrete: true}, + ); + sync(a, b); + await Promise.resolve(); + a.editor.update( + () => { + if (change === 'remove') { + $unwrap(target); + } else { + const visit = (node: ElementNode) => { + for (const child of node.getChildren()) { + if ($isElementNode(child)) { + if ($is(target, child) && $isLinkNode(child)) { + child.setURL('https://new.test'); + } + visit(child); + } + } + }; + visit($getRoot()); + } + $setSelection(null); + }, + {discrete: true}, + ); + b.editor.update( + () => { + for (const text of $wrappedTexts()) { + text.remove(); + } + $setSelection(null); + }, + {discrete: true}, + ); + await Promise.resolve(); + sync(a, b); + await Promise.resolve(); + const expected = fresh(a.doc); + expect(json(a.editor), 'a = fresh').toEqual(expected); + expect(json(b.editor), 'b = fresh').toEqual(expected); + if (change === 'remove') { + expect(count(a.editor, target), 'a: removed').toBe(0); + expect(count(b.editor, target), 'b: removed').toBe(0); + } else { + for (const editor of [a.editor, b.editor]) { + expect(urls(editor, target), 'retargeted').toEqual([ + 'https://new.test', + ]); + } + } + }, + ); +}); + +// A wrapper with no text (only a line break or an inline decorator) that +// stores text fields of its own: one client appends text into it while the +// other removes it, or gives a link another URL. The removal or the new URL +// stays on every client: the append leaves the wrapper holding text, whose +// text fields are not written, and its value without them does not write the +// stored one again over the content it kept. +describe('a textless wrapper with stored text fields that a client appends text into', () => { + const cells = SHAPES.filter( + ({leaf, fields}) => + fields === 'own' && (leaf === 'no-text' || leaf === 'decorator'), + ).flatMap(shape => + shape.chain.flatMap(target => + (target === 'link' || target === 'autolink' + ? (['remove', 'retarget'] as const) + : (['remove'] as const) + ).flatMap(change => + ( + [ + [1, 2], + [2, 1], + ] as [number, number][] + ).map(ids => ({...shape, change, ids, target})), + ), + ), + ); + test.each(cells)( + '$label: $change $target, ids $ids.0,$ids.1', + async ({chain, leaf, fields, change, ids, target}) => { + const seed = seedYState(config, () => $build(chain, leaf, fields)); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + a.editor.update( + () => { + if (change === 'remove') { + $unwrap(target); + } else { + const visit = (node: ElementNode) => { + for (const child of node.getChildren()) { + if ($isElementNode(child)) { + if ($is(target, child) && $isLinkNode(child)) { + child.setURL('https://new.test'); + } + visit(child); + } + } + }; + visit($getRoot()); + } + $setSelection(null); + }, + {discrete: true}, + ); + b.editor.update( + () => { + // Into the innermost wrapper. + let node = ($getRoot().getFirstChildOrThrow() as ElementNode) + .getChildren() + .find($isElementNode) as ElementNode; + for ( + let inner = node.getChildren().find($isElementNode); + inner !== undefined && $isElementNode(inner) && inner.isInline(); + inner = node.getChildren().find($isElementNode) + ) { + node = inner as ElementNode; + } + node.append($createTextNode('typed')); + $setSelection(null); + }, + {discrete: true}, + ); + await Promise.resolve(); + sync(a, b); + await Promise.resolve(); + const expected = fresh(a.doc); + expect(json(a.editor), 'a = fresh').toEqual(expected); + expect(json(b.editor), 'b = fresh').toEqual(expected); + if (change === 'remove') { + expect(count(a.editor, target), 'a: removed').toBe(0); + } else { + expect(urls(a.editor, target), 'retargeted').toEqual([ + 'https://new.test', + ]); + } + }, + ); +}); + +// Removal races: every wrapper of a chain, removed by one client while the +// other formats a range covering it, in both id orders. +type Race = Shape & {removed: Kind; ids: [number, number]}; +const RACES: Race[] = []; +for (const shape of SHAPES) { + for (const removed of shape.chain) { + for (const ids of [ + [1, 2], + [2, 1], + ] as [number, number][]) { + RACES.push({...shape, ids, removed}); + } + } +} + +/** + * A link that holds no text (only an inline decorator or a line break): + * Lexical serializes its textFormat and textStyle, so the binding stores + * them in the link's value, and the format change still writes that value + * and brings the removed link back (#9627). + */ +function knownTextlessLink({removed, leaf}: Race): boolean { + return removed === 'link' && (leaf === 'decorator' || leaf === 'no-text'); +} + +describe('a wrapper one client removes stays removed while another formats over it', () => { + for (const [known, races] of [ + [false, RACES.filter(r => !knownTextlessLink(r))], + [true, RACES.filter(knownTextlessLink)], + ] as const) { + (known ? test.fails : test).each(races)( + `$label: remove $removed, ids $ids.0,$ids.1${known ? ' (#9627)' : ''}`, + async ({chain, leaf, fields, removed, ids}) => { + const seed = seedYState(config, () => $build(chain, leaf, fields)); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + a.editor.update(() => $unwrap(removed), {discrete: true}); + expect(count(a.editor, removed)).toBe(0); + b.editor.update( + () => { + $selectAll().formatText('code'); + }, + {discrete: true}, + ); + sync(a, b); + await Promise.resolve(); + const expected = fresh(a.doc); + expect(count(a.editor, removed), 'a').toBe(0); + expect(count(b.editor, removed), 'b').toBe(0); + expect(json(a.editor), 'a = fresh').toEqual(expected); + expect(json(b.editor), 'b = fresh').toEqual(expected); + }, + ); + } +}); + +const note = createState('wrapper-text-fields-note', { + parse: (v: unknown) => (typeof v === 'string' ? v : ''), +}); + +/** The first wrapper `kind` makes, at any depth. */ +function $find(kind: Kind): ElementNode { + let found: ElementNode | null = null; + const visit = (node: ElementNode) => { + for (const child of node.getChildren()) { + if (found === null && $isElementNode(child)) { + if ($is(kind, child)) { + found = child; + } else { + visit(child); + } + } + } + }; + visit($getRoot()); + if (found === null) { + throw new Error(`no ${kind}`); + } + return found; +} + +// A change to the link itself (here NodeState) writes its value again, and +// that write brings back a link a collaborator removed at the same time +// (#9629): not a text format or style, so not covered by #9597's fix. +describe('a link one client removes while another sets NodeState on it', () => { + test.fails.each( + ([['link'], ['link', 'mark'], ['mark', 'link']] as Kind[][]).flatMap( + chain => + ( + [ + [1, 2], + [2, 1], + ] as [number, number][] + ).map(ids => ({chain, ids, label: chain.join('>')})), + ), + )('$label: ids $ids.0,$ids.1 (#9629)', async ({chain, ids}) => { + const seed = seedYState(config, () => $build(chain, 'text', 'none')); + const a = client(seed, ids[0]); + const b = client(seed, ids[1]); + await Promise.resolve(); + a.editor.update(() => $unwrap('link'), {discrete: true}); + b.editor.update(() => void $setState($find('link'), note, 'checked'), { + discrete: true, + }); + sync(a, b); + await Promise.resolve(); + expect(count(a.editor, 'link'), 'a').toBe(0); + expect(count(b.editor, 'link'), 'b').toBe(0); + }); +}); diff --git a/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFieldsCost.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFieldsCost.test.ts new file mode 100644 index 00000000000..59d12cca88c --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFieldsCost.test.ts @@ -0,0 +1,260 @@ +/** + * 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 + +// A change that leaves n formatted wrappers holding no text (the text in a +// mark inside each of n links is deleted, keeping a line break) shows each +// wrapper's stored text fields: the formats stored at the marks are found in +// one pass along the document, not one per wrapper from its start (see +// $unstoredWrapperTextFields), whether a collaborator's delete is read or +// the client deletes in one update of its own. Counted as the items checked +// (isGone), which grow linearly with n. + +import {$createLinkNode} from '@lexical/link'; +import {$createMarkNode} from '@lexical/mark'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, +} from 'lexical'; +import {expect, test, vi} from 'vitest'; + +import {expectConverged, setup} from '../../utils/harness'; + +const counter = vi.hoisted(() => ({on: false, visits: 0})); + +vi.mock('../../../internal/Seen', async importOriginal => { + const original = + await importOriginal(); + return { + ...original, + isGone: (...args: Parameters) => { + if (counter.on) { + counter.visits++; + } + return original.isGone(...args); + }, + }; +}); + +const $build = (n: number) => () => { + for (let i = 0; i < n; i++) { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('before '), + $createLinkNode(`https://link${i}.test`).append( + $createMarkNode([`c${i}`]).append( + $createTextNode(`word${i}`), + $createLineBreakNode(), + ), + ), + $createTextNode(' after'), + ), + ); + } +}; + +const $boldAll = () => { + const texts = $getRoot().getAllTextNodes(); + const last = texts[texts.length - 1]; + const selection = texts[0].select(0, 0); + selection.focus.set(last.getKey(), last.getTextContentSize(), 'text'); + selection.formatText('bold'); + $setSelection(null); +}; + +const $deleteWords = () => { + for (const text of $getRoot().getAllTextNodes()) { + if (text.getTextContent().startsWith('word')) { + text.remove(); + } + } + $setSelection(null); +}; + +test('wrappers a remote delete leaves without text are settled in linear time', async () => { + const visits: number[] = []; + for (const n of [32, 64, 128]) { + const t = await setup(() => { + for (let i = 0; i < n; i++) { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('before '), + $createLinkNode(`https://link${i}.test`).append( + $createMarkNode([`c${i}`]).append( + $createTextNode(`word${i}`), + $createLineBreakNode(), + ), + ), + $createTextNode(' after'), + ), + ); + } + }); + try { + t.b.update(() => { + const texts = $getRoot().getAllTextNodes(); + const last = texts[texts.length - 1]; + const selection = texts[0].select(0, 0); + selection.focus.set(last.getKey(), last.getTextContentSize(), 'text'); + selection.formatText('bold'); + $setSelection(null); + }); + await t.sync(); + t.offline(); + t.a.update(() => { + for (const text of $getRoot().getAllTextNodes()) { + if (text.getTextContent().startsWith('word')) { + text.remove(); + } + } + $setSelection(null); + }); + counter.visits = 0; + counter.on = true; + await t.sync(); + counter.on = false; + visits.push(counter.visits); + await expectConverged(t.peers); + } finally { + counter.on = false; + for (const p of t.peers) { + p.stop(); + } + } + } + // Doubling n doubles the work (about 2x); one pass per wrapper from the + // start of the document would quadruple it. + expect(visits[1] / visits[0]).toBeLessThan(3); + expect(visits[2] / visits[1]).toBeLessThan(3); +}, 60_000); + +test('wrappers a local delete leaves without text are settled in linear time', async () => { + const visits: number[] = []; + for (const n of [32, 64, 128]) { + const t = await setup($build(n)); + try { + t.b.update($boldAll); + await t.sync(); + counter.visits = 0; + counter.on = true; + t.b.update($deleteWords); + counter.on = false; + visits.push(counter.visits); + expect( + t.b.read(() => + $getRoot() + .getAllTextNodes() + .some(text => text.getTextContent().startsWith('word')), + ), + ).toBe(false); + await t.sync(); + await expectConverged(t.peers); + } finally { + counter.on = false; + for (const p of t.peers) { + p.stop(); + } + } + } + expect(visits[1] / visits[0]).toBeLessThan(3); + expect(visits[2] / visits[1]).toBeLessThan(3); +}, 60_000); + +// One line of n links, each "wN" and a line break, bolded by the client +// that then deletes every link's text in one update: each link shows the +// text fields stored at its first atom, found in one walk of the line. +// Counted as the items' `right` links followed during the update. +test('links a local delete leaves without text in one line are settled in linear time', async () => { + const reads: number[] = []; + for (const n of [32, 64, 128]) { + const t = await setup(() => { + const paragraph = $createParagraphNode(); + for (let i = 0; i < n; i++) { + paragraph.append( + $createTextNode(' '), + $createLinkNode(`https://link${i}.test`).append( + $createTextNode(`w${i}`), + $createLineBreakNode(), + ), + ); + } + $getRoot().append(paragraph); + }); + try { + t.b.update(() => { + const texts = $getRoot().getAllTextNodes(); + const last = texts[texts.length - 1]; + const selection = texts[0].select(0, 0); + selection.focus.set(last.getKey(), last.getTextContentSize(), 'text'); + selection.formatText('bold'); + $setSelection(null); + }); + await t.sync(); + let count = 0; + const restores: (() => void)[] = []; + const share = [ + ...(t.b.doc as unknown as {share: Map}).share.values(), + ][0] as {_start: {right: unknown} | null}; + for ( + let it = share._start as {right: unknown} | null; + it !== null; + it = it.right as {right: unknown} | null + ) { + const item = it; + const descriptor = Object.getOwnPropertyDescriptor(item, 'right')!; + const value = item.right; + Object.defineProperty(item, 'right', { + configurable: true, + get() { + count++; + return value; + }, + set(v) { + Object.defineProperty(item, 'right', { + ...descriptor, + value: v, + }); + }, + }); + restores.push(() => { + if ( + Object.getOwnPropertyDescriptor(item, 'right')!.get !== undefined + ) { + Object.defineProperty(item, 'right', {...descriptor, value}); + } + }); + } + try { + t.b.update(() => { + for (const text of $getRoot().getAllTextNodes()) { + if (/^w\d+$/.test(text.getTextContent())) { + text.remove(); + } + } + $setSelection(null); + }); + } finally { + for (const restore of restores) { + restore(); + } + } + reads.push(count); + await t.sync(); + await expectConverged(t.peers); + } finally { + for (const p of t.peers) { + p.stop(); + } + } + } + expect(reads[1] / reads[0]).toBeLessThan(3); + expect(reads[2] / reads[1]).toBeLessThan(3); +}, 60_000); diff --git a/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFieldsOwnDelete.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFieldsOwnDelete.test.ts new file mode 100644 index 00000000000..8e68a9c4c9c --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFieldsOwnDelete.test.ts @@ -0,0 +1,181 @@ +/** + * 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 + +// The client that formats a link whose text sits in a mark, then deletes +// that text itself (keeping a line break): the link holds no text, so +// Lexical serializes its textFormat, which was set while the link held text +// and is stored nowhere. The link shows the stored value in that same +// update, so no commit of the binding follows for an app to answer: an +// update listener that answers collaboration-tagged commits (with a change +// of the link's attributes, or with a change the binding does not write) +// never runs. Text fields the same update sets on the link are its own, and +// are stored. + +import {$createLinkNode} from '@lexical/link'; +import {$createMarkNode} from '@lexical/mark'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, + COLLABORATION_TAG, + type ElementNode, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import { + $selectIn, + expectConverged, + flush, + freshReader, + setup, +} from '../../utils/harness'; + +const $link = () => + ($getRoot().getFirstChildOrThrow() as ElementNode) + .getChildren() + .find(n => n.getType() === 'link') as ElementNode; + +for (const reply of ['none', 'attrs', 'refused'] as const) { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + test(`own delete leaves the link with its stored fields, reply ${reply}, ids ${ids}`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('before '), + $createLinkNode('https://example.test').append( + $createMarkNode(['comment']).append( + $createTextNode('word'), + $createLineBreakNode(), + ), + ), + $createTextNode(' after'), + ), + ); + }, + {ids}, + ); + try { + t.b.update(() => { + $selectIn([0], 0, 11).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + const undoStack = t.b.binding.undoManager!.undoStack; + const before = undoStack.length; + let tagged = 0; + let replies = 0; + const stop = t.b.editor.registerUpdateListener(({tags}) => { + if (!tags.has(COLLABORATION_TAG)) { + return; + } + tagged++; + if (reply === 'none' || replies >= 12) { + return; + } + replies++; + t.b.editor.update( + () => { + if (reply === 'attrs') { + $link().setTextStyle('color: red'); + } else { + $getRoot().append( + $createParagraphNode().append($createTextNode('reply')), + ); + } + }, + {discrete: true, tag: 'block-id-repair'}, + ); + }); + t.b.update(() => { + for (const text of $getRoot().getAllTextNodes()) { + if (text.getTextContent() === 'word') { + text.remove(); + } + } + $setSelection(null); + }); + await t.sync(); + for (let i = 0; i < 3; i++) { + await flush(); + } + stop(); + expect(tagged, 'no commit of the binding follows').toBe(0); + expect(undoStack.length, 'one undo step').toBe(before + 1); + expect(t.b.read(() => $link().getTextFormat())).toBe(0); + const fresh = await freshReader(t.b); + expect(t.b.json()).toEqual(fresh.json()); + await expectConverged(t.peers); + } finally { + for (const p of t.peers) { + p.stop(); + } + } + }); + } +} + +for (const format of [0, 2, 16]) { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + test(`text fields set in the update that leaves the link without text are stored, format ${format}, ids ${ids}`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('before '), + $createLinkNode('https://example.test').append( + $createMarkNode(['comment']).append( + $createTextNode('word'), + $createLineBreakNode(), + ), + ), + $createTextNode(' after'), + ), + ); + }, + {ids}, + ); + try { + t.b.update(() => { + $selectIn([0], 0, 11).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + t.b.update(() => { + for (const text of $link().getAllTextNodes()) { + text.remove(); + } + $link().setTextFormat(format).setTextStyle('color: red'); + $setSelection(null); + }); + await t.sync(); + const $fields = () => ({ + format: $link().getTextFormat(), + style: $link().getTextStyle(), + }); + expect(t.b.read($fields)).toEqual({format, style: 'color: red'}); + const fresh = await freshReader(t.b); + expect(fresh.read($fields)).toEqual({format, style: 'color: red'}); + await expectConverged(t.peers); + } finally { + for (const p of t.peers) { + p.stop(); + } + } + }); + } +} diff --git a/packages/lexical-y/src/internal/Binding.ts b/packages/lexical-y/src/internal/Binding.ts index 671394ceee2..873e6911da4 100644 --- a/packages/lexical-y/src/internal/Binding.ts +++ b/packages/lexical-y/src/internal/Binding.ts @@ -42,6 +42,7 @@ import { $getNodeByKey, $getRoot, $getSelection, + $hasUpdateTag, $isElementNode, $isNodeSelection, $isRangeSelection, @@ -90,11 +91,18 @@ import { markFormatCloser, } from './Cursor'; import {$canonicalizeGroup, $switchesWrapper} from './Groups'; -import {$applyStoredAttrs, Reader} from './Read'; +import { + $applyStoredAttrs, + $unstoredWrapperTextFields, + Reader, + type StoredFormatsMemo, +} from './Read'; import { $isGroupNode, $isWrapper, $nodeAttrs, + $setTextFields, + type Attrs, canStoreAs, deriveStorageKind, getGroupSpec, @@ -109,6 +117,8 @@ import { type StorageKind, storageKindOf, withPins, + wrapperFieldsOf, + wrappersFromAttrs, } from './Schema'; import { attrsBefore, @@ -866,6 +876,12 @@ export class YBinding { private unread: Unread | null = null; /** While a change is read: the document as it is (see readChange). */ private reading = false; + /** The stored formats one update's transforms looked up (see settleTextFields). */ + private storedFormats: { + state: EditorState | null; + sequences: StoredFormatsMemo; + lines: Map; + } = {lines: new Map(), sequences: new Map(), state: null}; /** This binding's view, for its work (see guard). */ private readonly seer = () => (this.reading ? null : this.unread); /** @@ -1164,10 +1180,116 @@ export class YBinding { $flushSyncAfterUpdate(); } }; + // A local change that leaves a wrapper holding no text (a delete of the + // text in a link, or in a mark inside a link) makes Lexical serialize the + // text fields this client set while the wrapper held text, which are + // stored nowhere: in the same update, the wrapper shows the stored ones, + // as every other client and a fresh reader do, and nothing follows the + // update for an app to answer. Otherwise the write would write the + // wrapper's whole value again over the content it keeps (a line break, + // an inline decorator), over a collaborator's concurrent removal or + // change of it. Runs on the element the change marks dirty: a stored + // element inside wrappers (see $unstoredWrapperTextFields), or the + // wrapper itself (its value is the one stored at its first atom). + const settleTextFields = (node: LexicalNode) => + withPins(this._pins, () => { + const state = this.state; + const stored = state.yByKey.has(node.getKey()); + if ( + this._status !== 'active' || + this.unread !== null || + state.hidden !== null || + !( + stored || + ($isElementNode(node) && node.isInline() && $isWrapper(node)) + ) || + $hasUpdateTag(COLLABORATION_TAG) + ) { + return; + } + const editor = this._editor; + // The stored formats are looked up once per update (the document + // does not change during its transforms). + const memo = this.storedFormats; + if (memo.state !== editor._pendingEditorState) { + memo.state = editor._pendingEditorState; + memo.sequences = new Map(); + memo.lines = new Map(); + } + const fieldsOf = (type: string) => wrapperFieldsOf(editor, type); + // The wrappers around the stored element (see + // $unstoredWrapperTextFields), or the wrapper itself when its own + // text changed (a delete of the text directly in a link); its value + // is the one stored at its first atom before the update. + const found: (readonly [ElementNode, Attrs | null, number, string])[] = + stored + ? $unstoredWrapperTextFields( + state, + [node.getKey()], + fieldsOf, + memo.sequences, + ).map( + ([wrapper, json]) => + [ + wrapper, + json, + wrapper.getTextFormat(), + wrapper.getTextStyle(), + ] as const, + ) + : (node as ElementNode).shouldSerializeTextStyles() && + ((node as ElementNode).getTextFormat() !== 0 || + (node as ElementNode).getTextStyle() !== '') + ? [ + [ + node as ElementNode, + null, + (node as ElementNode).getTextFormat(), + (node as ElementNode).getTextStyle(), + ] as const, + ] + : []; + if (found.length === 0) { + return; + } + // Only a wrapper this update left without text, with the text fields + // it had before the update: ones this update set (on a wrapper + // without text, or in a transform) are stored by the write. + const stale = editor.read('latest', () => + found.flatMap(([wrapper, json, format, style]) => { + const was = $getNodeByKey(wrapper.getKey()); + if ( + !$isElementNode(was) || + was.shouldSerializeTextStyles() || + was.getTextFormat() !== format || + was.getTextStyle() !== style + ) { + return []; + } + if (json !== null) { + return [[wrapper, json] as const]; + } + const value = this.$storedWrapperValue(was, memo.lines); + return value === null ? [] : [[wrapper, value] as const]; + }), + ); + for (const [wrapper, json] of stale) { + const {textFormat, textStyle} = json; + if ( + wrapper.getTextFormat() !== + (typeof textFormat === 'number' ? textFormat : 0) || + wrapper.getTextStyle() !== + (typeof textStyle === 'string' ? textStyle : '') + ) { + $setTextFields(wrapper, json); + } + } + }); for (const {klass} of this._editor._nodes.values()) { if (klass.prototype instanceof ElementNode) { this.disposers.push( this._editor.registerNodeTransform(klass, noteTransformed), + this._editor.registerNodeTransform(klass, settleTextFields), // The node structure normalization never reaches a named slot. this._editor.registerNodeTransform(klass, $wrapSlotShadowRootInline), ); @@ -4312,6 +4434,110 @@ export class YBinding { return found; } + /** + * The value stored for `wrapper` (in the state being read, which the + * document stores) at its first atom: its JSON as wrappersFromAttrs reads + * it, or null. Each line's atoms, and the offsets of the wrappers in it, + * are found once (`lines`, kept for one update). + */ + private $storedWrapperValue( + wrapper: ElementNode, + lines: Map, + ): Attrs | null { + const owner = $ownerOf(wrapper); + if (owner === null) { + return null; + } + let line = lines.get(owner.getKey()); + if (line === undefined) { + line = {atoms: this.storedAtoms(owner), offsets: $wrapperOffsets(owner)}; + lines.set(owner.getKey(), line); + } + const offset = line.offsets.get(wrapper.getKey()); + if (offset === undefined) { + return null; + } + // The atom range holding `offset` (ranges are in order). + const {atoms} = line; + let lo = 0; + let hi = atoms.length - 1; + let at: [number, number, Attrs] | undefined; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const [from, to] = atoms[mid]; + if (offset < from) { + hi = mid - 1; + } else if (offset >= to) { + lo = mid + 1; + } else { + at = atoms[mid]; + break; + } + } + if (at === undefined) { + return null; + } + const editor = this._editor; + const value = wrappersFromAttrs(at[2], type => + wrapperFieldsOf(editor, type), + ).find(v => v.type === wrapper.getType()); + return value === undefined ? null : value.json; + } + + /** + * The content of the line (or inline container) `owner` as stored: each + * content item's atom range and the formats in effect at it. + */ + private storedAtoms(owner: ElementNode): [number, number, Attrs][] { + const state = this.state; + const unit = state.unitByKey.get(owner.getKey()); + let start: Item | null; + let formats: Map; + if (unit !== undefined && unit.end !== null) { + const left = prevLiveEnd(unit.end, state.hidden); + start = left === null ? unit.cs.y._start : left.right; + formats = new Map(unit.start || []); + } else if (unit !== undefined && unit.kind === 'implicit') { + const units = unit.cs.units; + const idx = units.indexOf(unit); + let left = idx > 0 ? units[idx - 1].end : null; + while (left !== null && !isShownEnd(left, state.hidden)) { + left = prevLiveEnd(left, state.hidden); + } + start = left === null ? unit.cs.y._start : left.right; + formats = new Map(unit.start || []); + } else { + const cs = state.csByKey.get(owner.getKey()); + if (cs === undefined || cs.kind !== 'i') { + return []; + } + start = cs.y._start; + formats = new Map(); + } + const out: [number, number, Attrs][] = []; + let offset = 0; + for (let it = start; it !== null; it = it.right) { + if (!it.deleted && it.content instanceof ContentFormat) { + if (it.content.value === null) { + formats.delete(it.content.key); + } else { + formats.set(it.content.key, it.content.value); + } + continue; + } + const len = shownLength(it, state.hidden); + if (len === 0) { + if (isShownEnd(it, state.hidden)) { + break; + } + continue; + } + out.push([offset, offset + len, Object.fromEntries(formats) as Attrs]); + offset += len; + } + return out; + } + /** Containers a write did not write a follow-up's change to (see refused). */ private noteRefused(writer: Writer, naming: boolean): void { for (const cs of writer.refused) { @@ -6343,6 +6569,30 @@ function $ownerOf(node: LexicalNode): ElementNode | null { return parent; } +/** A line's stored atoms and the offsets of its wrappers (see YBinding). */ +type StoredLine = { + atoms: [number, number, Attrs][]; + offsets: Map; +}; + +/** The atom offset of every wrapper in the content of `owner`, in one pass. */ +function $wrapperOffsets(owner: ElementNode): Map { + const out = new Map(); + let n = 0; + const visit = (el: ElementNode) => { + for (const c of el.getChildren()) { + if ($isWrapper(c)) { + out.set(c.getKey(), n); + visit(c as ElementNode); + } else { + n += $atomSize(c); + } + } + }; + visit(owner); + return out; +} + function $atomsBefore(owner: ElementNode, target: LexicalNode): number { let n = 0; const visit = (el: ElementNode): boolean => { diff --git a/packages/lexical-y/src/internal/Read.ts b/packages/lexical-y/src/internal/Read.ts index b94916c3856..30203cf2614 100644 --- a/packages/lexical-y/src/internal/Read.ts +++ b/packages/lexical-y/src/internal/Read.ts @@ -49,6 +49,7 @@ import { $foreignFlatKeys, $groupFields, $isGroupNode, + $isWrapper, $levelItemFields, $lineAttrs, $lineBreakAttrs, @@ -103,6 +104,7 @@ import { type WrapperValue, } from './Schema'; import { + isGone, isHeld, isShown, isShownEnd, @@ -286,6 +288,11 @@ export class Reader { * type it does not register is an unknown type (see State#unknownTypes). */ private readonly wrapperFields: (type: string) => OwnedFields | null; + /** + * Stored elements (a mark, an overflow) whose children this read set (see + * $settleWrapperTextFields). + */ + private readonly childrenSet = new Set(); /** Item -> unit cache for one read. */ private located = new Map(); /** Inline owners already read from the current document without a hold. */ @@ -407,6 +414,7 @@ export class Reader { try { read(); if (this.depth === 1) { + this.$settleWrapperTextFields(); this.$checkTakenOut(); } } finally { @@ -422,6 +430,7 @@ export class Reader { try { this.$readFullInner(cs, el); if (this.depth === 1) { + this.$settleWrapperTextFields(); this.$checkTakenOut(); } } finally { @@ -2666,6 +2675,24 @@ export class Reader { const stack: Frame[] = []; const finish = (frame: Frame) => { this.$setChildren(frame.node, frame.children); + // A wrapper that holds no text (any more: a collaborator deleted its + // text around a line break) shows the textFormat and textStyle its + // value stores, as a fresh reader does: they are stored exactly while + // Lexical serializes them (see $wrapperAttrs), and one this client set + // while the wrapper held text was not stored. + const node = frame.node.getLatest(); + const {textFormat, textStyle} = frame.value.json; + if ( + (node.getTextFormat() !== + (typeof textFormat === 'number' ? textFormat : 0) || + node.getTextStyle() !== + (typeof textStyle === 'string' ? textStyle : '')) && + node.shouldSerializeTextStyles() + ) { + this.$updateStored(node, () => + $setTextFields(node.getLatest(), frame.value.json), + ); + } }; const runLength = (from: number, sig: string) => { let n = 0; @@ -2866,9 +2893,16 @@ export class Reader { const own: Attrs = {}; $wrapperAttrs(reused, own); const current = wrappersFromAttrs(own, this.wrapperFields)[0]; + // Without the text fields: they are stored only while the wrapper + // holds no text, which its children, set after this, decide, and are + // shown from the stored value then (see $readInline's `finish`). + const sansText = (json: Attrs) => { + const {textFormat: _f, textStyle: _s, ...rest} = json; + return stableStringify(rest); + }; if ( current === undefined || - stableStringify(current.json) !== stableStringify(w.json) + sansText(current.json) !== sansText(w.json) ) { const spec = getWrapperSpec(klassOf(reused))!; const json = $schemaJSON(reused); @@ -2900,8 +2934,30 @@ export class Reader { ); } + /** + * The wrappers around a stored element (a link around a mark) whose + * content this read changed, without reading the wrappers' line: one that + * now holds no text shows the textFormat and textStyle its value stores, + * as wrappers built by a read do (see $readInline's `finish`). + */ + private $settleWrapperTextFields(): void { + for (const [wrapper, json] of $unstoredWrapperTextFields( + this.state, + this.childrenSet, + this.wrapperFields, + )) { + this.$updateStored(wrapper, () => + $setTextFields(wrapper.getLatest(), json), + ); + } + this.childrenSet.clear(); + } + /** Make `el`'s children exactly `children`, keeping unchanged ends. */ $setChildren(el: ElementNode, children: LexicalNode[]): void { + if (this.state.yByKey.has(el.getKey())) { + this.childrenSet.add(el.getKey()); + } const prev = el.getChildren(); let left = 0; const max = Math.min(prev.length, children.length); @@ -3643,3 +3699,169 @@ function isDeleted(y: YNode): boolean { } return false; } + +/** + * The wrappers around the stored elements `keys` (a link around a mark, and + * around that link's own stored ancestors up to the line) that hold no text + * and show other text fields than their stored value, with that value's. + * Lexical serializes a wrapper's textFormat and textStyle while it holds no + * text, and the binding stores them exactly then (see $wrapperAttrs), so + * every client must show the stored ones: one a client set while the + * wrapper held text was not stored. The wrappers above a stored element are + * formats in the sequence that holds its item; above the next stored + * element, in that one's. + */ +export function $unstoredWrapperTextFields( + state: State, + keys: Iterable, + wrapperFields: (type: string) => OwnedFields | null, + memo?: StoredFormatsMemo, +): [ElementNode, Attrs][] { + const candidates: [ElementNode, Item][] = []; + const seen = new Set(); + for (const key of keys) { + const el = $getNodeByKey(key); + let anchor = state.yByKey.get(key); + if (el === null || anchor === undefined) { + continue; + } + for ( + let w: LexicalNode | null = el.getParent(); + $isElementNode(w); + w = w.getParent() + ) { + const wy = state.yByKey.get(w.getKey()); + // Inline content ends at the line: a stored element that is not + // inline (OverflowNode) still sits in it where its parent is inline. + const parent = w.getParent(); + if ( + !w.isInline() && + !(wy !== undefined && $isElementNode(parent) && parent.isInline()) + ) { + break; + } + if (wy !== undefined) { + anchor = wy; + continue; + } + if ( + anchor._item !== null && + !seen.has(w.getKey()) && + $isWrapper(w) && + w.shouldSerializeTextStyles() + ) { + seen.add(w.getKey()); + candidates.push([w, anchor._item]); + } + } + } + if (candidates.length === 0) { + return []; + } + const attrs = attrsAtItems( + candidates.map(([, item]) => item), + memo, + ); + const out: [ElementNode, Attrs][] = []; + for (const [w, item] of candidates) { + const value = wrappersFromAttrs(attrs.get(item)!, wrapperFields).find( + v => v.type === w.getType(), + ); + if (value === undefined) { + continue; + } + const {textFormat, textStyle} = value.json; + if ( + w.getTextFormat() !== (typeof textFormat === 'number' ? textFormat : 0) || + w.getTextStyle() !== (typeof textStyle === 'string' ? textStyle : '') + ) { + out.push([w, value.json]); + } + } + return out; +} + +/** + * The formats stored in effect at the stored nodes (Y.Node items) of the + * sequences read so far, by sequence: for lookups from several calls while + * the document does not change (the transforms of one update). + */ +export type StoredFormatsMemo = Map>; + +/** + * The formats stored in effect at each of `items`: one pass along each + * sequence that holds some of them (up to the last one, or, with `memo`, + * along all of it once, noting them at every stored node). + */ +function attrsAtItems( + items: Item[], + memo?: StoredFormatsMemo, +): Map { + const bySequence = new Map>(); + for (const item of items) { + let set = bySequence.get(item.parent); + if (set === undefined) { + set = new Set(); + bySequence.set(item.parent, set); + } + set.add(item); + } + const out = new Map(); + for (const [parent, wanted] of bySequence) { + let known = memo === undefined ? undefined : memo.get(parent); + if (memo !== undefined && known === undefined) { + known = new Map(); + memo.set(parent, known); + const formats = new Map(); + for ( + let it = (parent as {_start: Item | null})._start; + it !== null; + it = it.right + ) { + if (it.content instanceof ContentType) { + known.set(it, Object.fromEntries(formats) as Attrs); + } else if (!isGone(it) && it.content instanceof ContentFormat) { + if (it.content.value === null) { + formats.delete(it.content.key); + } else { + formats.set(it.content.key, it.content.value); + } + } + } + } + if (known !== undefined) { + let all = true; + for (const item of wanted) { + const attrs = known.get(item); + if (attrs === undefined) { + all = false; + } else { + out.set(item, attrs); + } + } + if (all) { + continue; + } + } + const formats = new Map(); + let left = wanted.size; + for ( + let it = (parent as {_start: Item | null})._start; + it !== null && left > 0; + it = it.right + ) { + if (wanted.has(it)) { + out.set(it, Object.fromEntries(formats) as Attrs); + left--; + } + if (!isGone(it) && it.content instanceof ContentFormat) { + if (it.content.value === null) { + formats.delete(it.content.key); + } else { + formats.set(it.content.key, it.content.value); + } + } + } + } + return out; +} diff --git a/packages/lexical-y/src/internal/Schema.ts b/packages/lexical-y/src/internal/Schema.ts index da5d7f9b89f..1e589c5db52 100644 --- a/packages/lexical-y/src/internal/Schema.ts +++ b/packages/lexical-y/src/internal/Schema.ts @@ -1975,6 +1975,21 @@ export function eachOuterLevel( */ const INSTANCE_KEY = '#'; +/** + * Wrapper values (see $wrapperAttrs) of wrappers that hold text: their text + * fields are not written (Lexical does not serialize them), so a value + * without them does not remove text fields stored there (see + * Writer#formatAsStored). + */ +const textHoldingValues = new WeakSet(); + +/** Whether `value` is a wrapper value of a wrapper that holds text. */ +export function isTextHoldingWrapperValue(value: unknown): boolean { + return ( + value !== null && typeof value === 'object' && textHoldingValues.has(value) + ); +} + /** * The format of one wrapper: one key, `w:`, whose value is a flat * object of its schema fields and NodeState (`s:`); a value that is not @@ -1995,7 +2010,20 @@ export function $wrapperAttrs( putValue(own, f, json[f], `${node.getType()}.${f}`); } $stateAttrs(node.getLatest(), own); - $putTextFields(node.getLatest(), own); + // Only when Lexical serializes them (`shouldSerializeTextStyles`: the + // wrapper holds no text anywhere inside it, and a mark in a link holds + // text), so that the author and a fresh reader have the same JSON. With + // text, they follow that text (a format of it sets them), and a write of + // them alone would write the whole value again over the wrapper's text, + // which a collaborator's concurrent removal of the wrapper does not delete + // (the removed link came back). + const latest = node.getLatest(); + if ( + (latest.getTextFormat() !== 0 || latest.getTextStyle() !== '') && + latest.shouldSerializeTextStyles() + ) { + $putTextFields(latest, own); + } for (const k in own) { const v = own[k]; // Yjs compares format values one level deep: a value that is not a @@ -2012,6 +2040,9 @@ export function $wrapperAttrs( if (instance !== null) { own[INSTANCE_KEY] = instance; } + if (!latest.shouldSerializeTextStyles()) { + textHoldingValues.add(own); + } // The innermost wrapper of a type wins. out[WRAPPER_PREFIX + node.getType()] = own; } @@ -2142,7 +2173,8 @@ export function $lineAttrs( /** * An element's textFormat/textStyle, which Lexical serializes only while * the element has no text: every peer must hold the same values, so groups - * and wrapper items always carry them (as lines do). + * and lines always carry them, and wrapper items while the wrapper holds no + * text (see $wrapperAttrs). */ function $putTextFields(node: ElementNode, out: Attrs): void { const tf = node.getTextFormat(); diff --git a/packages/lexical-y/src/internal/Write.ts b/packages/lexical-y/src/internal/Write.ts index 2f316e73640..11ea98c9582 100644 --- a/packages/lexical-y/src/internal/Write.ts +++ b/packages/lexical-y/src/internal/Write.ts @@ -73,6 +73,7 @@ import { INLINE_PREFIX, isMergeableText, isTextFieldKey, + isTextHoldingWrapperValue, KIND_KEY, klassOf, LEVEL_KEY, @@ -3735,7 +3736,16 @@ export class Writer { ) { return value; } - const owns = (m: string) => ownsMember(m, fields); + // A wrapper that holds text writes no text fields (Lexical does not + // serialize them), and does not remove ones stored there either: when a + // wrapper with no text that stores them gets text (a line break in a + // link, then text appended), its value is not written again over the + // content it kept, over a collaborator's concurrent removal or change + // of it (see $wrapperAttrs). + const keepsText = isTextHoldingWrapperValue(value); + const owns = (m: string) => + ownsMember(m, fields) && + !(keepsText && (m === 'textFormat' || m === 'textStyle')); if (same) { const a = value as Attrs; const b = stored as Attrs; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 9c359284bb5..d031da3201e 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -1358,6 +1358,8 @@ declare export function $getDocument(): Document; declare export function $getEditor(): LexicalEditor; /** @internal */ declare export function INTERNAL_getActiveEditorState(): EditorState | null; +/** @internal */ +declare export function INTERNAL_$withExportMemo(f: () => T): T; declare export function $getEditorDOMRenderConfig( editor?: LexicalEditor, ): EditorDOMRenderConfig; diff --git a/packages/lexical/src/LexicalEditorState.ts b/packages/lexical/src/LexicalEditorState.ts index 9ff33a2b346..89e1922ec97 100644 --- a/packages/lexical/src/LexicalEditorState.ts +++ b/packages/lexical/src/LexicalEditorState.ts @@ -23,7 +23,7 @@ import {cloneMap} from './LexicalGenMap'; import {$exportNodeJSON, $withCompactExport} from './LexicalSerializedExport'; import {$getSlot, $getSlotNames} from './LexicalSlot'; import {readEditorState} from './LexicalUpdates'; -import {$getRoot} from './LexicalUtils'; +import {$getRoot, INTERNAL_$withExportMemo} from './LexicalUtils'; import { $isElementNode, type SerializedElementNode, @@ -321,9 +321,11 @@ export class EditorState { // write the compact form for the latter. Anything that is not a stated // form is the legacy one, which is what both overloads promise. return $withCompactExport(typeof compact === 'boolean' && compact, () => - readEditorState(null, this, () => ({ - root: $exportNodeToJSON($getRoot()) as SerializedRootNode, - })), + readEditorState(null, this, () => + INTERNAL_$withExportMemo(() => ({ + root: $exportNodeToJSON($getRoot()) as SerializedRootNode, + })), + ), ); } } diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index be001c0d88b..fb846784367 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -87,6 +87,7 @@ import { getStaticNodeConfig, getSuperclassOf, internalMarkNodeAsDirty, + internalStopExportMemo, } from './LexicalUtils'; const __DEV__ = process.env.NODE_ENV !== 'production'; @@ -1646,6 +1647,9 @@ export class LexicalNode { return this; } errorOnReadOnly(); + // Before the clone, whose hooks may read: a node that changes ends an + // export walk's memo (see INTERNAL_$withExportMemo). + internalStopExportMemo(); const editorState = getActiveEditorState(); const editor = getActiveEditor(); const key = this.__key; diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 8a003db32f4..b2e351a1550 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -3129,6 +3129,90 @@ export function $isKeyInSelectionNodes( return nodes.some(node => node.__key === key); } +/** + * For each element of a committed (frozen) EditorState, whether it holds a + * TextNode at any depth, as ElementNode#shouldSerializeTextStyles asks for an + * inline element. Without it, serializing inline elements nested inside each + * other, each with a textFormat or textStyle, walks the same subtree once per + * level. A frozen state never changes, so its entries never go stale. + */ +const textInsideCache = new WeakMap>(); + +/** + * @internal + * The cache of $getTextInsideCache's frozen state for the active state, or + * null where none is known to hold: a pending state uses the cache of the + * committed state it was made from only while no node in it has been marked + * dirty (written, created or removed), when it holds exactly that state's + * nodes. A copy serializes in such an update. + */ +export function $getTextInsideCache(): Map | null { + let editorState = getActiveEditorState(); + if (!editorState._readOnly) { + const editor = internalGetActiveEditor(); + if ( + editor === null || + editor._pendingEditorState !== editorState || + editor._dirtyType !== NO_DIRTY_NODES || + !editor._editorState._readOnly + ) { + return exportMemo !== null && exportMemo.editorState === editorState + ? exportMemo.holds + : null; + } + editorState = editor._editorState; + } + let cache = textInsideCache.get(editorState); + if (cache === undefined) { + cache = new Map(); + textInsideCache.set(editorState, cache); + } + return cache; +} + +/** + * The memo of one export walk in a pending state that has changed (see + * INTERNAL_$withExportMemo), or null. + */ +let exportMemo: null | { + editorState: EditorState; + holds: Map; +} = null; +/** Whether an export walk is running (with or without its memo). */ +let exportWalk = false; + +/** + * @internal + * Run an export walk (EditorState#toJSON, the clipboard's selection export) + * with a memo of what $getTextInsideCache would hold, for an update whose + * pending state has changed and so has no cache of its own. The memo lives + * for the walk alone, which changes no node: a node made writable during the + * walk (see internalStopExportMemo) drops it for the rest of the walk, so it + * cannot answer for content that has changed. + */ +export function INTERNAL_$withExportMemo(f: () => T): T { + if (exportWalk) { + return f(); + } + const editorState = internalGetActiveEditorState(); + exportWalk = true; + exportMemo = + editorState !== null && !editorState._readOnly + ? {editorState, holds: new Map()} + : null; + try { + return f(); + } finally { + exportWalk = false; + exportMemo = null; + } +} + +/** @internal Drop the export walk's memo (see INTERNAL_$withExportMemo). */ +export function internalStopExportMemo(): void { + exportMemo = null; +} + /** @internal */ export type TypeToNodeMap = Map; /** diff --git a/packages/lexical/src/__tests__/unit/InlineElementTextFields.test.ts b/packages/lexical/src/__tests__/unit/InlineElementTextFields.test.ts new file mode 100644 index 00000000000..b3dc5c2ed76 --- /dev/null +++ b/packages/lexical/src/__tests__/unit/InlineElementTextFields.test.ts @@ -0,0 +1,335 @@ +/** + * 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 + +import {$generateJSONFromSelectedNodes} from '@lexical/clipboard'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $create, + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $parseSerializedNode, + $selectAll, + defineExtension, + ElementNode, + type LexicalNode, + type SerializedLexicalNode, +} from 'lexical'; +import {describe, expect, test, vi} from 'vitest'; + +// An element's textFormat and textStyle are persisted only when it has no +// text a reconcile would derive them from (#7968). A reconcile derives them +// from the element's first text at any depth; for an inline element (a link, +// a mark) the JSON now follows that rule too, so a link whose text sits in a +// mark no longer persists them. A block keeps the direct-children check, so +// the JSON of a paragraph whose text is all inside links is unchanged. + +class InlineWrapperNode extends ElementNode { + $config() { + return this.config('test-inline-wrapper', {extends: ElementNode}); + } + isInline(): boolean { + return true; + } +} + +/** An inline element whose clone reads the JSON of the node it is made from. */ +class CloneReadsJSONNode extends InlineWrapperNode { + $config() { + return this.config('test-clone-reads-json', {extends: InlineWrapperNode}); + } + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + prevNode.exportJSON(); + } +} + +function $wrapper(...children: LexicalNode[]): InlineWrapperNode { + return $create(InlineWrapperNode) + .setTextFormat(1) + .setTextStyle('color: red') + .append(...children); +} + +function withEditor(fn: () => void): void { + using editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[inline-element-text-fields]', + nodes: [InlineWrapperNode, CloneReadsJSONNode], + }), + ); + editor.update(fn, {discrete: true}); +} + +type Fields = {textFormat?: number; textStyle?: string}; + +function fields(node: ElementNode): Fields { + const json = node.exportJSON(); + return {textFormat: json.textFormat, textStyle: json.textStyle}; +} + +/** + * What ElementNode persists for a block (ParagraphNode's exportJSON then + * backfills them from its first text child for #7971). + */ +function serialized(node: ElementNode): Fields { + return { + textFormat: node.getSerializedTextFormat(), + textStyle: node.getSerializedTextStyle(), + }; +} + +const PERSISTED: Fields = {textFormat: 1, textStyle: 'color: red'}; +const OMITTED: Fields = {textFormat: undefined, textStyle: undefined}; + +describe('textFormat and textStyle of an inline element', () => { + test('are omitted when its text sits in an inline element inside it', () => { + withEditor(() => { + const inner = $wrapper($createTextNode('text')); + const outer = $wrapper(inner); + const outermost = $wrapper($wrapper($wrapper($createTextNode('deep')))); + $getRoot().append($createParagraphNode().append(outer, outermost)); + expect(fields(outer)).toEqual(OMITTED); + expect(fields(inner)).toEqual(OMITTED); + expect(fields(outermost)).toEqual(OMITTED); + }); + }); + + test('are omitted when it holds text of its own, as before', () => { + withEditor(() => { + const wrapper = $wrapper($createTextNode('text')); + $getRoot().append($createParagraphNode().append(wrapper)); + expect(fields(wrapper)).toEqual(OMITTED); + }); + }); + + test('are persisted when it holds no text at any depth, as before', () => { + withEditor(() => { + const empty = $wrapper(); + const lineBreak = $wrapper($createLineBreakNode()); + const nested = $wrapper($wrapper($createLineBreakNode())); + $getRoot().append( + $createParagraphNode().append(empty, lineBreak, nested), + ); + expect(fields(empty)).toEqual(PERSISTED); + expect(fields(lineBreak)).toEqual(PERSISTED); + expect(fields(nested)).toEqual(PERSISTED); + }); + }); + + test('a stored value is read and no longer written once it has text inside', () => { + withEditor(() => { + const stored = { + children: [ + { + children: [ + { + detail: 0, + format: 1, + mode: 'normal', + style: 'color: red', + text: 'text', + type: 'text', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + textFormat: 1, + textStyle: 'color: red', + type: 'test-inline-wrapper', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + textFormat: 1, + textStyle: 'color: red', + type: 'test-inline-wrapper', + version: 1, + } as SerializedLexicalNode; + const outer = $parseSerializedNode(stored) as InlineWrapperNode; + expect(outer.getTextFormat()).toBe(1); + expect(outer.getTextStyle()).toBe('color: red'); + $getRoot().append($createParagraphNode().append(outer)); + expect(fields(outer)).toEqual(OMITTED); + }); + }); +}); + +describe('textFormat and textStyle of a block', () => { + test('are persisted when its text is all inside inline elements, as before', () => { + withEditor(() => { + const paragraph = $createParagraphNode() + .setTextFormat(1) + .setTextStyle('color: red') + .append($wrapper($createTextNode('text'))); + $getRoot().append(paragraph); + expect(serialized(paragraph)).toEqual(PERSISTED); + }); + }); + + test('are omitted when it holds text of its own, as before', () => { + withEditor(() => { + const paragraph = $createParagraphNode() + .setTextFormat(1) + .setTextStyle('color: red') + .append($createTextNode('text'), $wrapper($createTextNode('more'))); + $getRoot().append(paragraph); + expect(serialized(paragraph)).toEqual(OMITTED); + }); + }); +}); + +/** getFirstChild calls while `fn` runs. */ +function countGetFirstChild(fn: () => void): number { + const getFirstChild = ElementNode.prototype.getFirstChild; + let count = 0; + const spy = vi + .spyOn(ElementNode.prototype, 'getFirstChild') + .mockImplementation(function (this: ElementNode) { + count++; + return getFirstChild.call(this); + }); + try { + fn(); + } finally { + spy.mockRestore(); + } + return count; +} + +/** Each node's exportJSON, as the clipboard exports a selection. */ +function $exportEach(node: LexicalNode): unknown { + const json = node.exportJSON() as {children?: unknown[]}; + if (node instanceof ElementNode) { + json.children = node.getChildren().map($exportEach); + } + return json; +} + +describe('serializing inline elements nested inside each other', () => { + for (const where of [ + 'a committed state', + 'an update', + 'a copy after a change in the same update', + ] as const) { + test(`stays linear in the depth when each carries text fields, in ${where}`, () => { + const calls: number[] = []; + for (const depth of [64, 128, 256]) { + using editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[inline-element-text-fields-depth]', + nodes: [InlineWrapperNode], + }), + ); + editor.update( + () => { + let node: LexicalNode = $createTextNode('leaf'); + for (let i = 0; i < depth; i++) { + node = $wrapper(node); + } + $getRoot().append($createParagraphNode().append(node)); + }, + {discrete: true}, + ); + let json = ''; + calls.push( + countGetFirstChild(() => { + if (where === 'a committed state') { + json = JSON.stringify(editor.getEditorState().toJSON()); + } else if (where === 'an update') { + editor.update( + () => { + json = JSON.stringify($exportEach($getRoot())); + }, + {discrete: true}, + ); + } else { + editor.update( + () => { + $getRoot().getAllTextNodes()[0].toggleFormat('italic'); + $selectAll(); + json = JSON.stringify( + $generateJSONFromSelectedNodes(editor, $getSelection()), + ); + }, + {discrete: true}, + ); + } + }), + ); + expect(json).toContain('leaf'); + } + // Doubling the depth doubles the walk (about 2x), where a walk of + // each element's whole subtree would quadruple it. + expect(calls[1] / calls[0]).toBeLessThan(3); + expect(calls[2] / calls[1]).toBeLessThan(3); + }); + } + + test('a change in an update is seen by the next serialization in it', () => { + withEditor(() => { + const text = $createTextNode('text'); + const inner = $wrapper(text); + const outer = $wrapper(inner); + $getRoot().append($createParagraphNode().append(outer)); + expect(fields(outer)).toEqual(OMITTED); + text.remove(); + inner.append($createLineBreakNode()); + expect(fields(outer)).toEqual(PERSISTED); + inner.append($createTextNode('again')); + expect(fields(outer)).toEqual(OMITTED); + }); + }); + + test('a clone that reads JSON before the change it is made for does not keep what the change replaces', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[inline-element-text-fields-clone]', + nodes: [InlineWrapperNode, CloneReadsJSONNode], + }), + ); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $create(CloneReadsJSONNode) + .setTextFormat(1) + .append($createTextNode('leaf')), + ), + ); + }, + {discrete: true}, + ); + let inside: Fields = {}; + editor.update( + () => { + const wrapper = ( + $getRoot().getFirstChildOrThrow() as ElementNode + ).getFirstChildOrThrow() as CloneReadsJSONNode; + wrapper.getFirstChildOrThrow().remove(); + inside = fields(wrapper); + }, + {discrete: true}, + ); + expect(inside.textFormat).toBe(1); + const committed = editor.getEditorState().toJSON() as unknown as { + root: {children: {children: Fields[]}[]}; + }; + expect(committed.root.children[0].children[0].textFormat).toBe(1); + }); +}); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index a832e852d33..87d34d40d57 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -449,6 +449,7 @@ export { getStaticNodeConfig, getTextDirection, INTERNAL_$isBlock, + INTERNAL_$withExportMemo, isBlockDomNode, isDocumentFragment, isDOMCapturingSelection, diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index ffca86f6a87..584b3141706 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -74,6 +74,7 @@ import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; import { $getDOMSlot, $getNodeByKey, + $getTextInsideCache, $isRootOrShadowRoot, getNodeKeyFromDOMNode, isDOMUnmanaged, @@ -167,8 +168,8 @@ const elementNodeSchema = nodeSchema()({ indent: withField(numberValue(0, {integer: true, min: 0}), { field: '__indent', }), - // Persisted only for an element with no TextNode child (see #7968), which - // `shouldSerializeTextStyles` decides. Declaring that as the field it is + // Persisted only for an element with no text to derive them from (see + // #7968), which `shouldSerializeTextStyles` decides. Declaring that as the field it is // plus the predicate that gates it — rather than reading through // `getSerializedTextFormat`/`getSerializedTextStyle` — keeps both on the // direct-field path, and lets generated code call the shared predicate once @@ -896,8 +897,12 @@ export class ElementNode } // JSON serialization /** - * Whether `textFormat`/`textStyle` are persisted at all: only when there are - * no TextNode children from which they would be set on reconcile (#7968). + * Whether `textFormat`/`textStyle` are persisted at all: only when there is + * no text from which they would be set on reconcile (#7968). That is a + * TextNode child, and for an inline element (a link, a mark) a TextNode + * anywhere inside it (a mark in a link), whose first text a reconcile takes + * them from as well. A block keeps the direct-children check, so its JSON + * is unchanged. * * @internal */ @@ -905,20 +910,7 @@ export class ElementNode if ($isRootOrShadowRoot(this)) { return false; } - // Walked rather than materialized with getChildren(), and it exits at the - // first TextNode. Both callers below test their own value first, so an - // element with a default textFormat/textStyle — every element that has - // many non-text children in practice — never reaches this at all. - for ( - let child = this.getFirstChild(); - child !== null; - child = child.getNextSibling() - ) { - if ($isTextNode(child)) { - return false; - } - } - return true; + return !$hasTextChild(this, this.isInline()); } /** @internal Serialized `textFormat`, or undefined to omit it. */ @@ -1088,6 +1080,59 @@ export function $isElementNode( return node instanceof ElementNode; } +/** + * Whether `element` has a TextNode child or, when `deep`, a TextNode at any + * depth inside it. Walked rather than materialized with getChildren(), and it + * exits at the first TextNode. Both callers of shouldSerializeTextStyles test + * their own value first, so an element with a default textFormat/textStyle + * (every element that has many non-text children in practice) never reaches + * this at all. + */ +function $hasTextChild(element: ElementNode, deep: boolean): boolean { + if (deep) { + return $hasTextInside(element, $getTextInsideCache()); + } + for ( + let child = element.getFirstChild(); + child !== null; + child = child.getNextSibling() + ) { + if ($isTextNode(child)) { + return true; + } + } + return false; +} + +/** + * $hasTextChild's deep walk; with a cache (see $getTextInsideCache), every + * element it visits is cached. + */ +function $hasTextInside( + element: ElementNode, + cache: Map | null, +): boolean { + const key = element.getKey(); + const cached = cache !== null ? cache.get(key) : undefined; + if (cached !== undefined) { + return cached; + } + let found = false; + for ( + let child = element.getFirstChild(); + child !== null && !found; + child = child.getNextSibling() + ) { + found = + $isTextNode(child) || + ($isElementNode(child) && $hasTextInside(child, cache)); + } + if (cache !== null) { + cache.set(key, found); + } + return found; +} + function isPointRemoved( point: PointType, nodesToRemoveKeySet: Set,