diff --git a/.changeset/lexical-y-composition-holdback.md b/.changeset/lexical-y-composition-holdback.md new file mode 100644 index 00000000000..27e3450b688 --- /dev/null +++ b/.changeset/lexical-y-composition-holdback.md @@ -0,0 +1,7 @@ +--- +'@lexical/y': patch +--- + +[fix] Keep the line being composed with an IME unchanged while collaborators add to it, and show every held change exactly once after composition ends, also after Enter or a paste made while composing + +Verify: IME composition (Chinese, Japanese or Korean input) in a paragraph a collaborator edits at the same time: the composition is not interrupted, and what they typed, formatted, split or pasted there shows when it ends. Check that a held insert followed by a multi-paragraph paste appears exactly once after composition ends, and that held collaborator inline formatting is visible when composition ends and Enter is pressed in the same update. With BlockIdExtension enabled, pasted lists and their items must agree with the block IDs stored in the document. diff --git a/packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs b/packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs index eecd9124b44..ec23f8526e6 100644 --- a/packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs @@ -218,6 +218,30 @@ test.describe('Collab Y: IME', () => { expect(blocks).toEqual(['paragraph:s1z', 'paragraph:kb1z s2z s3z 日本']); }); + test('compose while a peer splits the line after the composition', async ({ + browser, + }) => { + const {clients, session} = await openClients(browser); + const [a, b] = clients; + await seed(clients, ['s1z s2z s3z s4z']); + await a.caret('s2z'); + await b.caret('s3z'); + await session.delay(); + await Promise.all([ + a.ime(NIHON, '日本', 80), + (async () => { + await b.page.waitForTimeout(120); + await b.press('Enter'); + })(), + ]); + const {blocks} = await finish(clients, { + present: ['s1z', 's2z', 's3z', 's4z', '日本'], + }); + // The node being composed keeps its DOM text node: the browser goes on + // composing in it, and commits only what it composed. + expect(blocks).toEqual(['paragraph:s1z s2z日本 s3z', 'paragraph: s4z']); + }); + test('compose in a line while a peer joins it into the previous line', async ({ browser, }) => { diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 951f5327c0c..fcad205d627 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -744,6 +744,15 @@ not merge as a user might expect: stays outside, and a collaborator's concurrent delete of a character there does not apply to the moved text. Store comments as NodeState instead, with [`MarkStateExtension`](./comments.md). +- **Input method composition.** While the editor composes with an input + method (IME) in a line, what collaborators add to that line (text, + formats and comments, a split, pasted lines, a change of its type) waits + until the composition ends, and the line keeps its nodes, so the browser's + composition is not disturbed. Then all of it shows where it is, also after + Enter or a paste made while composing. What removes something from the + line (a deletion, a join, a `MarkNode` comment, which moves the text into + it) shows at once: the text node being composed keeps its key, unless its + class changes (a code block made a heading). - **Set-valued NodeState** (`arraySetValue`, which `MarkStateExtension` uses) merges per member on text, line breaks and nodes stored as their own Yjs node (such as decorators), so two collaborators commenting on the same diff --git a/packages/lexical-y/src/__tests__/unit/Composition.test.ts b/packages/lexical-y/src/__tests__/unit/Composition.test.ts index 219f75002fd..45a05760e7f 100644 --- a/packages/lexical-y/src/__tests__/unit/Composition.test.ts +++ b/packages/lexical-y/src/__tests__/unit/Composition.test.ts @@ -195,18 +195,22 @@ describe('@lexical/y IME composition with concurrent remote edits', () => { ); }); - test(`a structural edit of the composing line is read at once (ids ${ids})`, async () => { + test(`a split of the composing line waits for the composition to end (ids ${ids})`, async () => { const t = await setup(() => $getRoot().append($p('one two')), {ids}); compositionStart(t.a, [0], 7); compositionStep(t.a, [0], 7, '', 'n'); await t.sync(); + const key = t.a.read(() => $text([0]).getKey()); t.b.update(() => $selectIn([0], 3).insertParagraph()); await t.sync(); - expect(t.a.read(() => $getRoot().getChildrenSize())).toBe(2); + // The composing line keeps its nodes while composing. + expect(t.a.read(() => $getRoot().getChildrenSize())).toBe(1); + expect(t.a.read(() => $text([0]).getKey())).toBe(key); + expect(lineText(t.a, [0])).toBe('one twon'); t.a.update(() => { - const node = $text([1]); - node.setTextContent(' twoに'); - node.select(6, 6); + const node = $text([0]); + node.setTextContent('one twoに'); + node.select(8, 8); $setCompositionKey(null); }); await t.sync(); @@ -304,10 +308,10 @@ describe('@lexical/y IME composition with concurrent remote edits', () => { ]) { test(`a peer joins the lines after typing into the composing line (ids ${ids})`, async () => { // The peer's insert into the composing line is held back (not shown - // until the composition ends); the join then reads the line again, so - // it shows everything: what the editor shows and what it holds back - // must stay complementary (the self-check compares them on the next - // local write). + // until the composition ends); the join, which cannot be held (Yjs + // does not keep the deleted line marker), is read at once without it: + // what the editor shows and what it holds back stay complementary (the + // self-check compares them on the next local write). const t = await setup(() => $getRoot().append($p('j1z'), $p('j2z')), { ids, }); @@ -325,7 +329,7 @@ describe('@lexical/y IME composition with concurrent remote edits', () => { second.remove(); }); await t.sync(); - expect(lineText(t.a, [0])).toBe('j1zにj2zZ'); + expect(lineText(t.a, [0])).toBe('j1zにj2z'); t.a.update(() => { const node = $getNodeByKey(key) as TextNode; node.setTextContent(node.getTextContent().replace('に', '日本')); diff --git a/packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts b/packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts new file mode 100644 index 00000000000..182a59f0727 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts @@ -0,0 +1,55 @@ +/** + * 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 promise of the composition hold-back (see Hidden), checked over line +// shapes, collaborators' changes, and the ways a composition ends: +// +// - While the local editor composes (IME) in a line, a collaborator's change +// to that line that Yjs keeps whole (an insert, a format, a comment, a +// split, a change of the line's type) does not change the line's nodes: +// the browser composes into their DOM. A change that removes something +// (a deletion, a join, a comment wrapped around text, which moves it) is +// read at once, and the node being composed keeps its key and its line, +// unless its class changes (a code line made a heading). +// - When the composition ends (completed, cancelled, cut short by a blur), +// or the local editor changes the line's structure while composing (Enter, +// Shift+Enter, Backspace or Delete across lines, a paste, a change of the +// line's type, after which the composition goes on), every held change +// shows where it is: the editor equals a fresh read of its document, as +// every peer's does, and nothing a collaborator did is lost. +// +// The matrix is in ../utils/compositionHoldBack.ts; part of it runs with +// BlockIdExtension in CompositionHoldBackBlockIds.test.ts. + +import {describe, test} from 'vitest'; + +import {ENDS, REMOTE, runCase, SHAPES} from '../utils/compositionHoldBack'; + +describe('@lexical/y composition hold-back', () => { + for (const [shapeName, shape] of Object.entries(SHAPES)) { + for (const [remoteName, remote] of Object.entries(REMOTE)) { + for (const end of ENDS) { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + const limited = + remote.limit !== undefined && remote.limit(shape, end); + (limited ? test.skip : test)( + `${shapeName} line, remote ${remoteName}, ${end} (ids ${ids})`, + async () => { + await runCase(shape, remote, end, ids); + }, + ); + } + } + } + } +}); diff --git a/packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts b/packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts new file mode 100644 index 00000000000..c56c32381d5 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts @@ -0,0 +1,93 @@ +/** + * 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 + +// Part of the composition hold-back matrix (see CompositionHoldBack.test.ts) +// with BlockIdExtension: the reads a held change makes keep the line's id +// and node too; and finding R-5 of the review of the hold-back. +import '../utils/blockIdsEnv'; + +import {blockIdState} from '@lexical/extension'; +import { + $getNodeByKey, + $getRoot, + $getState, + $setCompositionKey, + $setState, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import {REMOTE, runCase, SHAPES} from '../utils/compositionHoldBack'; +import { + $block, + $p, + expectConverged, + setup, + WITH_BLOCK_IDS, +} from '../utils/harness'; + +delete process.env.LEXICAL_Y_BLOCK_ID; + +const ENDINGS = ['completed', 'Enter', 'a change of the line type'] as const; + +describe('@lexical/y composition hold-back with block ids', () => { + for (const shapeName of ['list item', 'plain']) { + for (const [remoteName, remote] of Object.entries(REMOTE)) { + for (const end of ENDINGS) { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + const shape = SHAPES[shapeName]; + const limited = + remote.limit !== undefined && remote.limit(shape, end); + (limited ? test.skip : test)( + `${shapeName} line, remote ${remoteName}, ${end} (ids ${ids})`, + async () => { + expect(WITH_BLOCK_IDS).toBe(true); + await runCase(shape, remote, end, ids); + }, + ); + } + } + } + } +}); + +describe('R-5: a peer changes the block id of the line A composes in', () => { + test('the line shows its old id until the composition ends', async () => { + expect(WITH_BLOCK_IDS).toBe(true); + const t = await setup(() => $getRoot().append($p('composed'), $p('next'))); + const id = () => t.a.read(() => $getState($block([0]), blockIdState)); + const was = id(); + let key = ''; + t.a.update(() => { + const node = $block([0]).getFirstChildOrThrow() as TextNode; + node.select(8, 8); + $setCompositionKey(node.getKey()); + key = node.getKey(); + }); + t.b.update(() => $setState($block([0]), blockIdState, 'remote-id')); + await t.sync(); + expect(id()).toBe(was); + // A composition step: an update that settles the ids it touched. + t.a.update(() => { + const node = $getNodeByKey(key) as TextNode; + node.setTextContent('composedに'); + node.select(9, 9); + }); + await t.sync(); + expect(id()).toBe(was); + t.a.update(() => $setCompositionKey(null)); + await t.sync(); + await expectConverged(t.peers); + expect(id()).toBe('remote-id'); + }); +}); diff --git a/packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts new file mode 100644 index 00000000000..b5925ccf154 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts @@ -0,0 +1,1333 @@ +/** + * 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 + +// Regression cases for IME composition findings (#9492, the soak7 findings +// S7-8, S7-9, S7-12, S7-15, S7-28 and S7-37, findings R-1 to R-4 of the +// review of the hold-back, and B-2 and MF-1 of a review of it with other +// changes): what a collaborator changes in the +// line being composed is held back until the composition ends, the line +// keeps its nodes meanwhile, and nothing held is lost when the hold ends. +// Each case is named after its finding; the general promise is checked over +// line shapes in CompositionHoldBack.test.ts. + +import {$createCodeNode} from '@lexical/code'; +import {registerCodeHighlighting} from '@lexical/code-prism'; +import { + $getBlockId, + BlockIdExtension, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import {$createListItemNode, $createListNode, $isListNode} from '@lexical/list'; +import {$addMarkID} from '@lexical/mark'; +import {$createHeadingNode} from '@lexical/rich-text'; +import {$setBlocksType} from '@lexical/selection'; +import * as Y from '@y/y'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $setCompositionKey, + $setSelection, + type ElementNode, + type NodeKey, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import { + $block, + $locateIn, + $p, + $selectIn, + expectConverged, + flush, + freshReader, + type Peer, + setup, +} from '../../utils/harness'; +import {prepareMarkState} from '../../utils/sizeOps'; + +const IDS = [ + [1, 2], + [2, 1], +]; + +const $first = (path: number[]) => + $block(path).getFirstChildOrThrow() as TextNode; + +/** Start composing at `offset` of text node `key`. */ +function compose(peer: Peer, key: NodeKey, offset: number): NodeKey { + peer.update(() => { + ($getNodeByKey(key) as TextNode).select(offset, offset); + $setCompositionKey(key); + }); + return key; +} + +const keyOf = (peer: Peer, path: number[]) => + peer.read(() => $first(path).getKey()); + +/** A composition step: text `prev` at `at` of node `key` becomes `next`. */ +function step( + peer: Peer, + key: NodeKey, + at: number, + prev: string, + next: string, +) { + peer.update(() => { + const node = $getNodeByKey(key) as TextNode; + const text = node.getTextContent(); + node.setTextContent( + text.slice(0, at) + next + text.slice(at + prev.length), + ); + node.select(at + next.length, at + next.length); + }); +} + +const endComposition = (peer: Peer) => + peer.update(() => $setCompositionKey(null)); + +const textOf = (peer: Peer, key: NodeKey) => + peer.read( + () => ($getNodeByKey(key) as TextNode | null)?.getTextContent() ?? null, + ); + +describe('#9492: a remote insert into a line made by joining two lines, while composing', () => { + for (const ids of IDS) { + for (const at of [1, 8]) { + test(`waits for the composition to end (insert at ${at}, ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $createParagraphNode().append($createTextNode('abc def')), + $createParagraphNode().append($createTextNode(' ghi')), + ), + {ids}, + ); + // A joins the two lines (as Backspace at the start of the second + // does): the line's stored range holds the deleted marker. + t.a.update(() => { + const s = $first([0]).select(7, 7); + s.focus.set($first([1]).getKey(), 0, 'text'); + s.removeText(); + }); + await t.sync(); + const key = compose(t.a, keyOf(t.a, [0]), 11); + step(t.a, key, 11, '', 'て'); + await t.sync(); + // B types before or after the join point. + t.b.update(() => $selectIn([0], at).insertText('XY')); + await t.sync(); + expect(textOf(t.a, key)).toBe('abc def ghiて'); + step(t.a, key, 11, 'て', '手'); + endComposition(t.a); + await t.sync(); + await expectConverged( + t.peers, + at === 1 + ? 'paragraph("aXYbc def ghi手")' + : 'paragraph("abc def XYghi手")', + ); + }); + } + } +}); + +describe('S7-8: a peer formats a word of a highlighted code line while A composes in it', () => { + for (const ids of IDS) { + for (const [name, composed] of [ + ['completed', ' t87z'], + ['cut short', ' t87'], + ] as const) { + test(`A, B and a fresh read agree after the composition (${name}, ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $createCodeNode('javascript').append( + $createTextNode('const t70z = fetch(t45z);'), + ), + ), + {ids, prepare: editor => void registerCodeHighlighting(editor)}, + ); + const $t70z = () => + $getRoot() + .getAllTextNodes() + .find(n => n.getTextContent().includes('t70z'))!; + const [start, at] = t.a.read(() => { + const node = $t70z(); + return [node.getKey(), node.getTextContent().indexOf('t70z') + 4]; + }); + const key = compose(t.a, start as NodeKey, at as number); + let prev = ''; + for (let i = 2; i <= composed.length; i++) { + step(t.a, key, at as number, prev, composed.slice(0, i)); + prev = composed.slice(0, i); + await t.sync(); + if (i === 3) { + t.b.update(() => { + const node = $t70z(); + const o = node.getTextContent().indexOf('t70z'); + node.select(o, o + 4).formatText('italic'); + }); + await t.sync(); + } + } + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + }); + } + } +}); + +describe('S7-9: a peer bolds or comments a word in the text node A composes in', () => { + for (const ids of IDS) { + for (const kind of ['bold', 'comment'] as const) { + test(`the composed node keeps its text until the composition ends (${kind}, ids ${ids})`, async () => { + const t = await setup(() => $getRoot().append($p('alpha beta gamma')), { + ids, + prepare: prepareMarkState, + }); + const key = compose(t.a, keyOf(t.a, [0]), 16); + step(t.a, key, 16, '', ' に'); + await t.sync(); + t.b.update(() => { + const s = $selectIn([0], 6, 10); + if (kind === 'bold') { + s.formatText('bold'); + } else { + $addMarkID('c1', s); + } + }); + await t.sync(); + expect(textOf(t.a, key)).toBe('alpha beta gamma に'); + step(t.a, key, 16, ' に', ' 日本'); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + expect( + t.a.read(() => + $getRoot() + .getAllTextNodes() + .map(n => n.getTextContent()), + ), + ).toEqual(['alpha ', 'beta', ' gamma 日本']); + }); + } + } +}); + +describe('S7-12: Enter while composing, after a peer typed in the composed line', () => { + for (const ids of IDS) { + for (const [name, at, composed, order] of [ + ['Enter at the composition start, then the end', 0, '', 'enter-end'], + ['Enter after composed text, then the end', 4, 'かん', 'enter-end'], + ['Enter mid-line, nothing composed, then the end', 4, '', 'enter-end'], + ['the end, then Enter in the same task', 0, '', 'end-enter'], + ] as const) { + test(`the held word shows, and stays (${name}, ids ${ids})`, async () => { + const t = await setup(() => $getRoot().append($p('s1z s2z s3z ')), { + ids, + }); + const key = compose(t.a, keyOf(t.a, [0]), at); + if (composed !== '') { + step(t.a, key, at, '', composed); + await t.sync(); + } + // B types a word after the composition: held back on A. + t.b.update(() => + $selectIn([0], at + composed.length + 5).insertText('kb5z '), + ); + await t.sync(); + expect(t.a.read(() => $getRoot().getTextContent())).not.toContain( + 'kb5z', + ); + const enter = () => + t.a.update(() => { + const s = $getSelection(); + if ($isRangeSelection(s)) { + s.insertParagraph(); + } + }); + if (order === 'enter-end') { + enter(); + endComposition(t.a); + } else { + endComposition(t.a); + enter(); + } + await t.sync(); + await expectConverged(t.peers); + expect(t.a.read(() => $getRoot().getTextContent())).toContain('kb5z'); + // A's next edit of that line keeps the word. + t.a.update(() => { + const last = $getRoot().getLastChildOrThrow() as ElementNode; + last.selectStart().insertText('new1z '); + }); + await t.sync(); + await expectConverged(t.peers); + expect(t.b.read(() => $getRoot().getTextContent())).toContain('kb5z'); + }); + } + } +}); + +describe('S7-15: a peer presses Enter later in the line A composes in', () => { + for (const ids of IDS) { + test(`the composed node keeps its key, text and line (ids ${ids})`, async () => { + const t = await setup( + () => $getRoot().append($p('t4z t5z t6z t7z t8z t9z t10z t11z')), + {ids}, + ); + const key = compose(t.a, keyOf(t.a, [0]), 3); + step(t.a, key, 3, '', 'n'); + await t.sync(); + const before = textOf(t.a, key); + // B presses Enter after "t8z". + t.b.update(() => $selectIn([0], 20).insertParagraph()); + await t.sync(); + t.a.read(() => { + const node = $getNodeByKey(key) as TextNode; + expect(node.isAttached()).toBe(true); + expect(node.getTopLevelElementOrThrow().getIndexWithinParent()).toBe(0); + expect(node.getTextContent()).toBe(before); + }); + step(t.a, key, 3, 'n', '你好'); + endComposition(t.a); + await t.sync(); + await expectConverged( + t.peers, + 'paragraph("t4z你好 t5z t6z t7z t8z") / paragraph(" t9z t10z t11z")', + ); + }); + } +}); + +/** How many times `word` is in the document. */ +const count = (peer: Peer, word: string) => + peer.read(() => $getRoot().getTextContent().split(word).length - 1); + +describe('L1C-3: ending composition after a held prefix insert and a mid-line paste', () => { + for (const ids of IDS) { + test(`L1C-3: every pasted token is stored once before and after the end (ids ${ids})`, async () => { + const t = await setup(() => $getRoot().append($p('[s1] [s2] [s3] ')), { + ids, + }); + const key = compose(t.a, keyOf(t.a, [0]), 10); + t.b.update(() => $selectIn([0], 0).insertText('[ke4] ')); + await t.sync(); + // The prefix is stored remotely, but still held on the composer. + expect(textOf(t.a, key)).toBe('[s1] [s2] [s3] '); + expect(count(t.a, '[ke4]')).toBe(0); + expect(count(await freshReader(t.a), '[ke4]')).toBe(1); + t.b.update(() => + $selectIn([0], 6).insertNodes([$p('[ke5] [ke6] '), $p('[ke7] [ke8] ')]), + ); + await t.sync(); + const words = [ + '[ke4]', + '[ke5]', + '[ke6]', + '[ke7]', + '[ke8]', + '[s1]', + '[s2]', + '[s3]', + ]; + for (const peer of [...t.peers, await freshReader(t.a)]) { + for (const word of words) { + expect(count(peer, word), `before end: ${word}`).toBe(1); + } + } + // No Enter or composition character is needed to duplicate the tail. + endComposition(t.a); + await t.sync(); + const fresh = await freshReader(t.a); + for (const peer of [...t.peers, fresh]) { + for (const word of words) { + expect(count(peer, word), `after end: ${word}`).toBe(1); + } + } + expect(t.a.describe()).toBe(fresh.describe()); + await expectConverged(t.peers); + }); + } +}); + +describe('L1C-4: a held bold opener survives same-update composition end and Enter', () => { + for (const ids of [ + [1, 2, 3], + [3, 2, 1], + ]) { + test(`L1C-4: the formerly plain prefix becomes bold and stays converged (ids ${ids})`, async () => { + const t = await setup(() => $getRoot().append($p('[s1] [s2] [s3] ')), { + ids, + }); + const endAndEnter = (peer: Peer) => + peer.update(() => { + $setCompositionKey(null); + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error('expected a composition selection'); + } + selection.insertParagraph(); + }); + // This history matters: a plain prefix inserted before an existing + // bold run makes the next remote format add only a bold opener. + t.a.update(() => $selectIn([0], 0, 7).formatText('bold')); + await t.sync(); + const first = compose(t.b, keyOf(t.b, [0]), 0); + step(t.b, first, 0, '', 'ㅎ'); + await t.sync(); + endAndEnter(t.b); + await t.sync(); + t.b.update(() => $selectIn([1], 0).insertText('[kb96] ')); + await t.sync(); + const prefixBold = (peer: Peer, path: number[]) => + peer.read(() => $first(path).hasFormat('bold')); + expect(prefixBold(t.a, [1])).toBe(false); + const key = compose(t.a, keyOf(t.a, [1]), 0); + t.c.update(() => $selectIn([1], 0, 7).formatText('bold')); + await t.sync(); + expect(prefixBold(t.a, [1])).toBe(false); + expect(prefixBold(await freshReader(t.a), [1])).toBe(true); + step(t.a, key, 0, '', 'ㄓ'); + await t.sync(); + endAndEnter(t.a); + await t.sync(); + const fresh = await freshReader(t.a); + for (const peer of [...t.peers, fresh]) { + expect(peer.read(() => $first([2]).getTextContent())).toContain( + '[kb96] ', + ); + expect(prefixBold(peer, [2]), 'released prefix must be bold').toBe( + true, + ); + expect(peer.warnings.filter(w => w.includes('self-check'))).toEqual([]); + } + expect(t.a.describe()).toBe(fresh.describe()); + await expectConverged(t.peers); + // $selectIn creates a plain selection: the new text may be plain, + // but editing before the released prefix must not undo its bold. + t.a.update(() => $selectIn([2], 0).insertText('[next] ')); + await t.sync(); + expect( + t.a.read(() => + $block([2]) + .getAllTextNodes() + .find(node => node.getTextContent().includes('[kb96]')) + ?.hasFormat('bold'), + ), + ).toBe(true); + expect(count(t.a, '[kb96]')).toBe(1); + expect(count(t.a, '[next]')).toBe(1); + expect(t.a.describe()).toBe((await freshReader(t.a)).describe()); + await expectConverged(t.peers); + }); + } +}); + +describe('L1C-4: re-splitting a held-derived successor preserves its inherited bold', () => { + for (const ids of [ + [1, 2, 3], + [3, 2, 1], + ]) { + for (const clearBeforeSplit of [false, true]) { + test(`L1C-4: the second successor becomes bold on release (clear before split ${clearBeforeSplit}, ids ${ids})`, async () => { + const t = await setup( + () => $getRoot().append($p('[s1] [s2] [s3] '), $p('UNTouched')), + {ids}, + ); + const untouched = keyOf(t.a, [1]); + t.a.update(() => $selectIn([0], 0, 7).formatText('bold')); + await t.sync(); + const first = compose(t.b, keyOf(t.b, [0]), 0); + step(t.b, first, 0, '', 'ㅎ'); + await t.sync(); + t.b.update(() => { + $setCompositionKey(null); + $selectIn([0], 1).insertParagraph(); + }); + await t.sync(); + t.b.update(() => $selectIn([1], 0).insertText('[kb96] ')); + await t.sync(); + const key = compose(t.a, keyOf(t.a, [1]), 0); + t.c.update(() => $selectIn([1], 0, 7).formatText('bold')); + await t.sync(); + expect(t.a.read(() => $first([1]).hasFormat('bold'))).toBe(false); + step(t.a, key, 0, '', 'ㄓ'); + await t.sync(); + // Keep composition in the original owner while its first successor + // inherits the withheld opener; then split that successor separately. + t.a.update(() => { + $selectIn([1], 1).insertParagraph(); + const owner = $first([1]); + owner.selectEnd(); + $setCompositionKey(owner.getKey()); + }); + await t.sync(); + const hidden = ( + t.a.binding as unknown as { + state: {hidden: unknown | null}; + } + ).state.hidden; + expect(hidden).not.toBeNull(); + t.a.update(() => { + if (clearBeforeSplit) { + $setCompositionKey(null); + } + $selectIn([2], 3).insertParagraph(); + }); + await t.sync(); + if (!clearBeforeSplit) { + endComposition(t.a); + await t.sync(); + } + const fresh = await freshReader(t.a); + for (const peer of [...t.peers, fresh]) { + expect(peer.read(() => $first([3]).getTextContent())).toContain( + '96] ', + ); + expect( + peer.read(() => $first([3]).hasFormat('bold')), + 'second successor must render the inherited bold on release', + ).toBe(true); + expect(peer.warnings.filter(w => w.includes('self-check'))).toEqual( + [], + ); + } + expect(t.a.describe()).toBe(fresh.describe()); + expect(keyOf(t.a, [4])).toBe(untouched); + await expectConverged(t.peers); + }); + } + } +}); + +describe('S8-4: composition steps after a held prefix and remote paste', () => { + test('S8-4: the throwing composition step and following step both survive in the own document', async () => { + const t = await setup( + () => + $getRoot().append( + $p('[s1] [s2] [s3] '), + $p('[s4] 👷‍♀️🚧 [s5] '), + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('[s6] [s7] ')), + $createListItemNode().append($createTextNode('[s8] ')), + ), + $p('[s9] [s10] '), + ), + {ids: [1, 2, 3, 4]}, + ); + const d = t.peers[3]; + const $paths = () => { + const paths: number[][] = []; + $getRoot() + .getChildren() + .forEach((node, i) => { + if ($isListNode(node)) { + node.getChildren().forEach((_, j) => paths.push([i, j])); + } else { + paths.push([i]); + } + }); + return paths; + }; + const $line = (li: number) => $block($paths()[li]); + const $selectLine = (li: number, at: number, end = at) => { + if ($line(li).getAllTextNodes().length === 0) { + return $line(li).select(0, 0); + } + const text = $line(li).getTextContent(); + const clamp = (offset: number) => { + let value = Math.min(offset, text.length); + const code = text.charCodeAt(value); + if (code >= 0xdc00 && code <= 0xdfff) value--; + return value; + }; + return $selectIn($paths()[li], clamp(at), clamp(end)); + }; + const $paste = (li: number, at: number, texts: string[], list = false) => { + const nodes: ElementNode[] = (list ? texts.slice(0, -1) : texts).map($p); + if (list) + nodes.push( + $createListNode('bullet').append( + $createListItemNode().append( + $createTextNode(texts[texts.length - 1]), + ), + ), + ); + $selectLine(li, at).insertNodes(nodes); + }; + const $remove = (li: number) => { + const line = $line(li); + const parent = line.getParent(); + $setSelection(null); + line.remove(); + if ($isListNode(parent) && parent.getChildrenSize() === 0) + parent.remove(); + }; + const $cross = (li: number, at: number, li2: number, end: number) => { + const selection = $selectLine(li, at); + const [node, offset] = $locateIn( + $line(li2), + Math.min(end, $line(li2).getTextContentSize()), + ); + selection.focus.set(node.getKey(), offset, 'text'); + selection.removeText(); + }; + // The reduced report's structural history is necessary: a plain initial + // line catches duplication, but not the cursor failure of the next step. + const history: [Peer, () => void][] = [ + [ + t.c, + () => $paste(0, 11, ['[kc4] [kc5] ', '[kc6] [kc7] ', '[kc8] [kc9] ']), + ], + [t.c, () => $selectLine(0, 0).insertParagraph()], + [d, () => $selectLine(3, 23).insertParagraph()], + [ + t.c, + () => + $paste( + 8, + 10, + [ + '[kc106] [kc107] ', + '[kc108] [kc109] ', + '[kc110] [kc111] ', + '[kc112] [kc113] ', + '[kc114] ', + ], + true, + ), + ], + [t.a, () => $cross(1, 0, 2, 0)], + [t.b, () => $selectLine(10, 16).insertText('[kb131] ')], + [t.a, () => $cross(4, 0, 5, 8)], + [d, () => $selectLine(9, 8).insertText('[kd134]🏳️‍🌈 ')], + [t.b, () => $remove(3)], + [t.b, () => $cross(5, 40, 6, 0)], + [d, () => $remove(6)], + [d, () => $selectLine(6, 30, 38).formatText('bold')], + [t.c, () => $remove(3)], + [t.c, () => $remove(3)], + [d, () => $cross(2, 0, 3, 10)], + [t.a, () => $selectLine(3, 38).insertText('[ka140] ')], + [t.b, () => $selectLine(3, 62).insertText('[kb143]🚧 ')], + [d, () => $selectLine(3, 46).insertText('[kd145] ')], + [d, () => $paste(0, 0, ['[kd146] [kd147] ', '[kd148] [kd149] '])], + [t.b, () => $cross(1, 16, 2, 22)], + ]; + for (const [peer, action] of history) { + peer.update(action); + await t.sync(); + } + const [key, at] = t.b.read(() => { + const [node, offset] = $locateIn( + $line(3), + Math.min(80, $line(3).getTextContentSize()), + ); + return [node.getKey(), offset] as const; + }); + compose(t.b, key, at); + t.a.update(() => $selectLine(3, 0).insertText('[ka156]😀 ')); + await t.sync(); + expect(count(t.b, '[ka156]')).toBe(0); + t.c.update(() => + $paste(3, 18, [ + '[kc157] [kc158] ', + '[kc159] [kc160] ', + '[kc161] [kc162] ', + ]), + ); + await t.sync(); + step(t.b, key, at, '', 'ㄓ'); + await t.sync(); + expect(t.b.errors).toEqual([]); + expect(count(t.b, 'ㄓ')).toBe(1); + expect(count(await freshReader(t.b), 'ㄓ')).toBe(1); + step(t.b, key, at, 'ㄓ', '中'); + await t.sync(); + expect(count(t.b, '中')).toBe(1); + expect(count(await freshReader(t.b), '中')).toBe(1); + endComposition(t.b); + await t.sync(); + for (const peer of [...t.peers, await freshReader(t.b)]) { + expect(peer.errors).toEqual([]); + expect(count(peer, 'ㄓ')).toBe(0); + for (const token of [ + '中', + '[ka156]', + '[kc157]', + '[kc158]', + '[kc159]', + '[kc160]', + '[kc161]', + '[kc162]', + ]) { + expect(count(peer, token), token).toBe(1); + } + } + expect(t.b.describe()).toBe((await freshReader(t.b)).describe()); + await expectConverged(t.peers); + }); +}); + +describe('S8-6: BlockIdExtension preserves pasted block NodeState across composition release', () => { + test('S8-6: pasted block ids agree with own-document readers and peers through following edits', async () => { + const t = await setup( + () => + $getRoot().append( + $p('[s1] [s2] [s3] '), + $p('[s4] 👷‍♀️🚧 [s5] '), + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('[s6] [s7] ')), + $createListItemNode().append($createTextNode('[s8] ')), + ), + $p('[s9] [s10] '), + ), + {ids: [1, 2, 3, 4, 5], withBlockIds: true}, + ); + const e = t.peers[4]; + // Address the same leaf-line indices as the eight-action report, without + // importing its untracked simulator or changing global environment flags. + const $paths = () => { + const paths: number[][] = []; + $getRoot() + .getChildren() + .forEach((node, i) => { + if ($isListNode(node)) { + node.getChildren().forEach((_, j) => paths.push([i, j])); + } else { + paths.push([i]); + } + }); + return paths; + }; + const $line = (li: number) => $block($paths()[li]); + const $selectLine = (li: number, at: number) => + $selectIn($paths()[li], Math.min(at, $line(li).getTextContentSize())); + const paste = ( + peer: Peer, + li: number, + at: number, + texts: string[], + list = false, + ) => + peer.update(() => { + const nodes: ElementNode[] = (list ? texts.slice(0, -1) : texts).map( + $p, + ); + if (list) { + nodes.push( + $createListNode('bullet').append( + $createListItemNode().append( + $createTextNode(texts[texts.length - 1]), + ), + ), + ); + } + $selectLine(li, at).insertNodes(nodes); + }); + for (const peer of t.peers) { + expect( + getExtensionDependencyFromEditor(peer.editor, BlockIdExtension).output + .disabled.value, + ).toBe(false); + } + t.c.update(() => $line(0).remove()); + await t.sync(); + paste(t.c, 0, 30, [ + '[kc7] [kc8] ', + '[kc9] [kc10] ', + '[kc11] [kc12] ', + '[kc13] [kc14] ', + ]); + await t.sync(); + paste(e, 4, 10, ['[ke20] [ke21] ', '[ke22] [ke23] ', '[ke24] [ke25] ']); + await t.sync(); + const key = t.c.read(() => $line(5).getFirstChildOrThrow().getKey()); + compose(t.c, key, 0); + t.b.update(() => $line(2).remove()); + await t.sync(); + e.update(() => { + const selection = $selectLine(3, 0); + selection.focus.set($line(4).getFirstChildOrThrow().getKey(), 0, 'text'); + selection.removeText(); + }); + await t.sync(); + e.update(() => $selectLine(3, 7).insertText('[ke33] ')); + await t.sync(); + paste( + t.a, + 3, + 14, + ['[ka34] [ka35] ', '[ka36] [ka37] ', '[ka38] [ka39] ', '[ka40] '], + true, + ); + await t.sync(); + endComposition(t.c); + await t.sync(); + const rows = (peer: Peer) => + peer.read(() => { + const result: {type: string; text: string; id: string | null}[] = []; + const visit = (block: ElementNode) => { + result.push({ + id: $getBlockId(block), + text: block.getTextContent(), + type: block.getType(), + }); + for (const child of block.getChildren()) { + if ($isElementNode(child)) visit(child); + } + }; + for (const child of $getRoot().getChildren()) { + if ($isElementNode(child)) visit(child); + } + return result; + }); + const check = async () => { + const fresh = await freshReader(t.c); + expect( + getExtensionDependencyFromEditor(fresh.editor, BlockIdExtension).output + .disabled.value, + ).toBe(false); + const stored = rows(fresh); + expect(stored.every(row => row.id !== null)).toBe(true); + expect(stored.some(row => row.text.includes('[ka40]'))).toBe(true); + for (const peer of t.peers) { + expect(rows(peer), 'stored blockId NodeState must match').toEqual( + stored, + ); + } + await expectConverged(t.peers); + }; + await check(); + t.c.update(() => $selectLine(3, 0).insertText('[next-c] ')); + await t.sync(); + await check(); + t.b.update(() => $selectLine(4, 0).insertText('[next-b] ')); + await t.sync(); + await check(); + }); +}); + +describe('S7-28: a peer pastes paragraphs into the composed line, then types in them', () => { + for (const ids of IDS) { + for (const [name, steps] of [ + ['one composition step', ['k']], + ['two steps', ['k', '漢']], + ] as const) { + test(`every word is stored once (${name}, ids ${ids})`, async () => { + const t = await setup( + () => $getRoot().append($p('p0z '), $p('t9z t10z ')), + { + ids, + }, + ); + const key = compose(t.a, keyOf(t.a, [1]), 9); + // B pastes two paragraphs at the start of that line (lines made + // before it, the last one's text joins it), then types in them. + t.b.update(() => { + $selectIn([1], 0).insertNodes([$p('pa1z pa2z '), $p('pb1z pb2z ')]); + }); + await t.sync(); + t.b.update(() => $selectIn([2], 5).insertText('kb140z ')); + await t.sync(); + let prev = ''; + for (const next of steps) { + step(t.a, key, 9, prev, next); + prev = next; + await t.sync(); + } + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + for (const word of ['kb140z', 'pa1z', 'pb2z', 't10z']) { + expect(count(t.a, word)).toBe(1); + } + }); + } + } + for (const ids of [ + [1, 2, 3], + [3, 2, 1], + ]) { + test(`edits made offline at the start of an emptied line, composed in (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $p('[s1] [s2] [s3] '), + $p('[s4] xxxxxxx [s5] '), + $p('[s6] [s7] '), + ), + {ids: [...ids, 4]}, + ); + const [a, b, c, d] = t.peers; + // C deletes most of line 2; B deletes line 1 up to line 2, which + // joins it, and then what is left of it: an empty line. + c.update(() => $selectIn([1], 14, 18).removeText()); + await t.sync(); + c.update(() => $selectIn([1], 7, 14).removeText()); + await t.sync(); + b.update(() => { + const s = $selectIn([0], 0); + s.focus.set($first([1]).getKey(), 0, 'text'); + s.removeText(); + }); + await t.sync(); + b.update(() => $selectIn([0], 0, 7).removeText()); + await t.sync(); + // Offline, D types at the line's start and presses Enter there, and A + // types at the line's start and starts composing there. + t.offline(); + d.update(() => $block([0]).select(0, 0).insertText('[kd29] ')); + d.update(() => $selectIn([0], 0).insertParagraph()); + a.update(() => $block([0]).select(0, 0).insertText('[ka39] ')); + const key = compose(a, keyOf(a, [0]), 0); + await t.sync(); + // B types in the line, and A composes a step. + b.update(() => { + const line = $block([1]); + const size = line.getTextContentSize(); + const s = + size === 0 ? line.select(0, 0) : $selectIn([1], Math.min(22, size)); + s.insertText('[kb42] '); + }); + await t.sync(); + step(a, key, 0, '', 'ㅎ'); + await t.sync(); + endComposition(a); + await t.sync(); + await expectConverged(t.peers); + for (const word of ['[kb42]', '[ka39]', '[kd29]']) { + expect(count(a, word)).toBe(1); + } + }); + test(`a line made by joining two, with a bold first word (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $p('[s1] [s2] [s3] '), + $p('[s4] [s5] '), + $p('[s9] '), + ), + {ids}, + ); + t.c.update(() => $selectIn([0], 0, 5).formatText('bold')); + // A joins the two lines after "[s1] ". + t.a.update(() => { + const s = $selectIn([0], 5); + s.focus.set($first([1]).getKey(), 0, 'text'); + s.removeText(); + }); + t.c.update(() => $selectIn([0], 0).insertText('[kd6] ')); + await t.sync(); + // B composes at the line start while A and C type in the line. + const key = compose( + t.b, + t.b.read(() => $block([0]).getFirstDescendant()!.getKey()), + 0, + ); + step(t.b, key, 0, '', 'ㅎ'); + await t.sync(); + t.c.update(() => $selectIn([0], 7).insertText('[kd14] ')); + t.a.update(() => $selectIn([0], 19).insertText('[ka15] ')); + await t.sync(); + step(t.b, key, 0, 'ㅎ', '하'); + await t.sync(); + endComposition(t.b); + await t.sync(); + await expectConverged(t.peers); + for (const word of ['[ka15]', '[kd14]', '[kd6]']) { + expect(count(t.a, word)).toBe(1); + } + }); + } +}); + +describe('S7-37: a peer types a word in the composed line and deletes text after it, then another types inside the word', () => { + for (const ids of [ + [1, 2, 3], + [3, 2, 1], + [2, 1, 3], + ]) { + test(`every word is stored once (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $p('[s1] [s2] '), + $p('[s4] ').append( + $createTextNode('xyzw yy ').toggleFormat('italic'), + $createTextNode('[s5] [s6] [s7] '), + ), + ), + {ids}, + ); + // A composes at the start of the line; B types a word in it (held + // back) and deletes text after the italic run (read at once). + const key = compose(t.a, keyOf(t.a, [1]), 0); + t.b.update(() => $selectIn([1], 5).insertText('[ke5] ')); + t.b.update(() => $selectIn([1], 11, 16).removeText()); + await t.sync(); + // C types inside B's word, and A composes a step. + t.c.update(() => $selectIn([1], 6).insertText('[kd10] ')); + await t.sync(); + step(t.a, key, 0, '', 'n'); + await t.sync(); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + for (const word of ['[kd10]', '[s5]', 'ke5]']) { + expect(count(t.a, word)).toBe(1); + } + }); + } +}); + +describe('R-1: a peer deletes the end of the text node A composes in, after a line break', () => { + for (const ids of IDS) { + test(`the composed node keeps its key, and the next step writes into it (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $p('first').append( + $createLineBreakNode(), + $createTextNode('second'), + ), + ), + {ids}, + ); + const key = compose( + t.a, + t.a.read(() => $block([0]).getLastChildOrThrow().getKey()), + 4, + ); + // "first", the line break and "second" are one Yjs string. + t.b.update(() => + ($block([0]).getLastChildOrThrow() as TextNode) + .select(4, 6) + .removeText(), + ); + await t.sync(); + expect(textOf(t.a, key)).toBe('seco'); + step(t.a, key, 4, '', 'に'); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers, 'paragraph("first" br "secoに")'); + }); + } +}); + +describe('R-2: a peer centers the line A composes in, then a read regroups the lines around it', () => { + for (const ids of IDS) { + test(`the line shows the alignment when the composition ends, not before (ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append( + $createListNode('bullet').append( + ...['pre', 'composed', 'next'].map(s => + $createListItemNode().append($createTextNode(s)), + ), + ), + ), + {ids}, + ); + const line = t.a.read(() => $block([0, 1]).getKey()); + const key = compose(t.a, keyOf(t.a, [0, 1]), 8); + const format = () => + t.a.read(() => ($getNodeByKey(line) as ElementNode).getFormatType()); + t.b.update(() => $block([0, 1]).setFormat('center')); + await t.sync(); + expect(format()).toBe(''); + // Enter at the end of the item before: the read regroups the list. + t.b.update(() => $block([0, 0]).selectEnd().insertParagraph()); + await t.sync(); + expect(format()).toBe(''); + step(t.a, key, 8, '', 'に'); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + expect(format()).toBe('center'); + }); + } +}); + +describe('R-3: a peer realigns the line A composes in while a local edit of A waits to be written', () => { + for (const ids of IDS) { + test(`the later alignment wins (ids ${ids})`, async () => { + const t = await setup(() => $getRoot().append($p('composed')), {ids}); + const key = compose(t.a, keyOf(t.a, [0]), 8); + const format = () => t.a.read(() => $block([0]).getFormatType()); + t.b.update(() => $block([0]).setFormat('center')); + await t.sync(); + expect(format()).toBe(''); + // A composes in an update that is not discrete: it is written when + // B's next change arrives, before that change is read. + t.a.editor.update(() => { + const node = $getNodeByKey(key) as TextNode; + node.setTextContent('composedに'); + node.select(9, 9); + }); + t.b.update(() => $block([0]).setFormat('right')); + await t.sync(); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + expect(format()).toBe('right'); + }); + } +}); + +describe('R-4: A joins the line it composes in into the line before, which a peer realigned or retyped', () => { + for (const ids of IDS) { + for (const field of ['alignment', 'type'] as const) { + test(`the line before keeps its own ${field} (ids ${ids})`, async () => { + const t = await setup( + () => $getRoot().append($p('pre'), $p('composed')), + { + ids, + }, + ); + const key = compose(t.a, keyOf(t.a, [1]), 1); + t.b.update(() => { + if (field === 'alignment') { + $block([1]).setFormat('center'); + } else { + $setBlocksType($selectIn([1], 0), () => $createHeadingNode('h2')); + } + }); + await t.sync(); + // Backspace at the start of the line. + t.a.update(() => + ($getNodeByKey(key) as TextNode).select(0, 0).deleteCharacter(true), + ); + await t.sync(); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers, 'paragraph("precomposed")'); + expect(t.a.read(() => $block([0]).getFormatType())).toBe(''); + }); + } + } +}); + +/** The markers of `peer`'s document, in order. */ +const markersOf = (peer: Peer) => + peer.binding.root.toArray().filter(c => c instanceof Y.Node) as Y.Node[]; + +describe('B-2: a peer realigns or retypes the line A composes in, then A presses Enter mid-line while composing', () => { + for (const ids of IDS) { + for (const change of [ + 'center', + 'heading', + 'center, then A aligns it left', + ] as const) { + test(`both halves keep it, the other lines do not get it (${change}, ids ${ids})`, async () => { + const t = await setup( + () => + $getRoot().append($p('pre'), $p('alpha beta gamma'), $p('omega')), + {ids}, + ); + const key = compose(t.a, keyOf(t.a, [1]), 6); + step(t.a, key, 6, '', 'に'); + await t.sync(); + t.b.update(() => { + if (change === 'heading') { + $setBlocksType($selectIn([1], 0), () => $createHeadingNode('h2')); + } else { + $block([1]).setFormat('center'); + } + }); + await t.sync(); + if (change === 'center, then A aligns it left') { + // An explicit local change of the held attribute wins. + t.a.update(() => $block([1]).setFormat('left')); + await t.sync(); + } + // Enter after the composed text. + t.a.update(() => { + ($getNodeByKey(key) as TextNode).select(7, 7).insertParagraph(); + }); + await t.sync(); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + const lines = t.b.read(() => + $getRoot() + .getChildren() + .map(n => + [ + n.getType(), + (n as ElementNode).getFormatType(), + n.getTextContent(), + ].join(':'), + ), + ); + const [type, format] = + change === 'heading' + ? ['heading', ''] + : change === 'center' + ? ['paragraph', 'center'] + : ['paragraph', 'left']; + expect(lines).toEqual([ + 'paragraph::pre', + `${type}:${format}:alpha に`, + `${type}:${format}:beta gamma`, + 'paragraph::omega', + ]); + }); + } + } +}); + +describe('MF-1: a newer client sets a field A does not know on the line A composes in', () => { + // What A holds back while composing in the line, and what each marker + // stores (the field, and the alignment) once A has pressed Enter in the + // middle of the line and the room has converged. + const run = async (composing: boolean, withOwned: boolean, ids: number[]) => { + const t = await setup( + () => $getRoot().append($p('pre'), $p('alpha beta gamma'), $p('omega')), + {ids}, + ); + const key = keyOf(t.a, [1]); + if (composing) { + compose(t.a, key, 6); + } + step(t.a, key, 6, '', 'に'); + await t.sync(); + // B writes the field to the line's marker (as a client whose class + // declares it), and an alignment A knows. + const marker = markersOf(t.b)[1]; + t.b.doc.transact(() => { + marker.setAttr('p:future', 'x'); + if (withOwned) { + marker.setAttr('p:format', 'center'); + } + }); + await t.sync(); + const hidden = ( + t.a.binding as unknown as { + state: { + hidden: {marker: {was: Map} | null} | null; + }; + } + ).state.hidden; + const held = + hidden === null || hidden.marker === null + ? [] + : [...hidden.marker.was.keys()]; + t.a.update(() => { + ($getNodeByKey(key) as TextNode).select(7, 7).insertParagraph(); + }); + await t.sync(); + if (composing) { + endComposition(t.a); + await t.sync(); + } + await expectConverged(t.peers); + const markers = markersOf(t.a).map(m => [ + m.getAttr('p:future'), + m.getAttr('p:format'), + ]); + return {held, markers}; + }; + for (const ids of IDS) { + for (const withOwned of [false, true]) { + test(`A holds nothing of it, and Enter stores it as without a composition (${withOwned ? 'with an alignment' : 'alone'}, ids ${ids})`, async () => { + const composed = await run(true, withOwned, ids); + const plain = await run(false, withOwned, ids); + expect(composed.held).toEqual(withOwned ? ['p:format'] : []); + expect(plain.held).toEqual([]); + // The composition changes nothing A stores. The field is on both + // halves of the split either way (see N1 in SchemaSkew.test.ts). + expect(composed.markers).toEqual(plain.markers); + if (withOwned) { + expect( + composed.markers.filter(([, format]) => format === 'center'), + ).toHaveLength(2); + } + }); + } + } +}); + +// N2: a composition step committed and written before a remote change the +// binding has not read yet (see Unread) is written on the document its +// editor shows: the alignment a collaborator's change set, which the +// composition holds back, is not written again as A's own, so the room ends +// as it does without the step. +describe('N2: a composition step written before an unread change of the held alignment', () => { + const run = async (ids: number[], stepInWindow: boolean) => { + const t = await setup( + () => $getRoot().append($p('pre'), $p('alpha beta gamma'), $p('omega')), + {ids}, + ); + const key = compose(t.a, keyOf(t.a, [1]), 6); + step(t.a, key, 6, '', 'に'); + await t.sync(); + t.b.update(() => $block([1]).setFormat('center')); + await t.sync(); + // B and C align the line at the same time, offline. + t.offline(); + const before = Y.encodeStateVector(t.b.doc); + t.b.update(() => $block([1]).setFormat('right')); + t.c.update(() => $block([1]).setFormat('justify')); + await flush(); + const change = Y.encodeStateAsUpdate(t.b.doc, before); + if (stepInWindow) { + // Not committed yet when B's change arrives in this task: it is + // committed and written first, and the change is read after it. + t.a.editor.update(() => { + const node = $getNodeByKey(key) as TextNode; + const text = node.getTextContent(); + node.setTextContent(text.slice(0, 7) + 'ほ' + text.slice(7)); + node.select(8, 8); + }); + } + Y.applyUpdate(t.a.doc, change); + await flush(); + let own = 0; + const items = markersOf(t.a)[1]._map; + for (let it = items.get('p:format') ?? null; it !== null; it = it.left) { + if (it.id.client === t.a.doc.clientID) { + own++; + } + } + await t.sync(); + endComposition(t.a); + await t.sync(); + await expectConverged(t.peers); + const formats = t.b.read(() => + $getRoot() + .getChildren() + .map(n => (n as ElementNode).getFormatType()), + ); + return {formats, own}; + }; + for (const ids of [ + [3, 1, 2], + [2, 1, 3], + [1, 2, 3], + ]) { + test(`writes no alignment of its own and ends as without the step (ids ${ids})`, async () => { + const withStep = await run(ids, true); + const without = await run(ids, false); + expect(withStep.own).toBe(0); + expect(withStep.formats).toEqual(without.formats); + }); + } +}); diff --git a/packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts index f9e83371972..b4a9808c3dd 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts @@ -2191,13 +2191,20 @@ describe('S7-21: what an older class does before it throws', () => { describe('S7-21 (MF-2): every read goes through the one fail-closed path', () => { /** Make `method` of the reader throw `error` once. */ - const throwOnce = (method: string, error: unknown) => { + const throwOnce = ( + method: string, + error: unknown, + when: (reader: unknown) => boolean = () => true, + ) => { const proto = Reader.prototype as unknown as Record< string, (...args: unknown[]) => unknown >; const original = proto[method]; proto[method] = function (this: unknown, ...args: unknown[]) { + if (!when(this)) { + return original.apply(this, args); + } proto[method] = original; throw error; }; @@ -2227,9 +2234,13 @@ describe('S7-21 (MF-2): every read goes through the one fail-closed path', () => (node as TextNode).spliceText(0, 0, 'XY'); }); await t.sync(); + // The release reads what was held where it is (see YBinding#$release), + // on the composing client. + const state = (t.a.binding as unknown as {state: unknown}).state; const restore = throwOnce( - '$refreshComposed', + '$apply', new Error('simulated reader failure'), + reader => (reader as {state: unknown}).state === state, ); try { expect(() => diff --git a/packages/lexical-y/src/__tests__/utils/compositionHoldBack.ts b/packages/lexical-y/src/__tests__/utils/compositionHoldBack.ts new file mode 100644 index 00000000000..1fccd778777 --- /dev/null +++ b/packages/lexical-y/src/__tests__/utils/compositionHoldBack.ts @@ -0,0 +1,616 @@ +/** + * 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. + * + */ + +// The composition hold-back matrix (see CompositionHoldBack.test.ts): line +// shapes, collaborators' changes, and the ways a composition ends. + +import {$createCodeNode} from '@lexical/code'; +import {registerCodeHighlighting} from '@lexical/code-prism'; +import {$createListItemNode, $createListNode} from '@lexical/list'; +import { + $addMarkID, + $getNodeMarkIDs, + $isMarkNode, + $wrapSelectionInMarkNode, +} from '@lexical/mark'; +import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; +import {$setBlocksType} from '@lexical/selection'; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, +} from '@lexical/table'; +import {$findMatchingParent} from '@lexical/utils'; +import { + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setCompositionKey, + $setSelection, + type ElementNode, + type LexicalNode, + type NodeKey, + type TextNode, +} from 'lexical'; +import {expect} from 'vitest'; + +import { + $block, + $locateIn, + $p, + $selectIn, + expectConverged, + type Peer, + type Setup, + setup, +} from './harness'; +import {prepareMarkState} from './sizeOps'; + +export type Shape = { + /** The document, with the line composed in holding `alpha beta gamma`. */ + init: () => void; + /** The composed line's path, once `prepare` ran. */ + path: number[]; + /** Edits made (and synced) before the composition: the line's history. */ + prepare?: (t: Setup) => Promise; + /** Whether the editors highlight code (a code block's derived nodes). */ + highlight?: boolean; + /** Whether the line is an item of a group (a list item). */ + item?: boolean; +}; + +const $text = (s: string) => $p(s).getFirstChildOrThrow() as TextNode; + +export const SHAPES: Record = { + 'code line': { + highlight: true, + init: () => + $getRoot().append( + $p('pre'), + $createCodeNode('javascript').append($text('alpha beta gamma')), + $p('omega'), + ), + path: [1], + }, + // A line whose text is three nodes: the composition is in the last one. + 'formatted run': { + init: () => + $getRoot().append( + $p('pre'), + $p('alpha ').append( + $createTextNode('beta').toggleFormat('italic'), + $createTextNode(' gamma'), + ), + $p('omega'), + ), + path: [1], + }, + // A line made by joining two (Backspace at the start of the second): its + // stored range holds the deleted marker between them (#9492). + joined: { + init: () => + $getRoot().append($p('pre'), $p('alpha beta'), $p(' gamma'), $p('omega')), + path: [1], + prepare: async t => { + t.a.update(() => { + const s = ($block([1]).getFirstChildOrThrow() as TextNode).select( + 10, + 10, + ); + s.focus.set($block([2]).getFirstChildOrThrow().getKey(), 0, 'text'); + s.removeText(); + }); + await t.sync(); + }, + }, + 'list item': { + init: () => + $getRoot().append( + $createListNode('bullet').append( + $createListItemNode().append($text('pre')), + $createListItemNode().append($text('alpha beta gamma')), + $createListItemNode().append($text('omega')), + ), + ), + item: true, + path: [0, 1], + }, + plain: { + init: () => + $getRoot().append($p('pre'), $p('alpha beta gamma'), $p('omega')), + path: [1], + }, + // A line made by splitting one (Enter): it ends at the new marker. + split: { + init: () => + $getRoot().append($p('pre'), $p('alpha beta gamma delta'), $p('omega')), + path: [1], + prepare: async t => { + t.a.update(() => $selectIn([1], 16).insertParagraph()); + await t.sync(); + }, + }, + 'table cell': { + init: () => + $getRoot().append( + $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append( + $p('pre'), + $p('alpha beta gamma'), + $p('omega'), + ), + $createTableCellNode().append($p('next')), + ), + ), + ), + path: [0, 0, 0, 1], + }, +}; + +/** + * What a read of a collaborator's change promises for the composed line: + * `held`, it keeps its nodes; `read`, the composed node keeps its key; + * `replaced`, its class changes, so it is a new node. + */ +export type Expected = 'held' | 'read' | 'replaced'; + +/** Changes a collaborator makes to the composed line, mid-composition. */ +export const REMOTE: Record< + string, + { + promise: (shape: Shape) => Expected; + /** The change, made by B in one update. */ + $edit?: (path: number[]) => void; + /** The change, made by B (and C) in several updates (each synced). */ + run?: (t: Setup, path: number[]) => Promise; + /** Whether the change needs a third peer, C. */ + third?: boolean; + /** The cases a known limit skips (see where it is set). */ + limit?: (shape: Shape, end: string) => boolean; + } +> = { + // B centers the line: its marker's attributes are held back. + align: { + $edit: path => $block(path).setFormat('center'), + promise: () => 'held', + }, + // B centers the line, then presses Enter at the end of the line before: + // a read of the lines around it keeps what is held. + 'align, then Enter in the line before': { + promise: () => 'held', + run: async (t, path) => { + t.b.update(() => $block(path).setFormat('center')); + await t.sync(); + const before = [...path.slice(0, -1), path[path.length - 1] - 1]; + t.b.update(() => $block(before).selectEnd().insertParagraph()); + await t.sync(); + }, + }, + comment: { + $edit: path => { + $addMarkID('c1', $selectIn(path, 6, 10)); + }, + promise: () => 'held', + }, + // B composes at the same place too, commits, and comments the word it + // committed (S7-28). + composition: { + promise: () => 'held', + run: async (t, path) => { + let key: NodeKey = ''; + t.b.update(() => { + const [node, offset] = $locateIn($block(path), 16); + node.select(offset, offset); + $setCompositionKey(node.getKey()); + key = node.getKey(); + }); + let at = 0; + let prev = ''; + for (const next of ['n', 'に', '日本']) { + t.b.update(() => { + const node = $getNodeByKey(key) as TextNode; + at = at === 0 ? $composedAt(node) : at; + const text = node.getTextContent(); + node.setTextContent( + text.slice(0, at) + next + text.slice(at + prev.length), + ); + node.select(at + next.length, at + next.length); + }); + prev = next; + await t.sync(); + } + t.b.update(() => { + $setCompositionKey(null); + const node = $getNodeByKey(key) as TextNode; + const s = node.select(at + 1, at + 2); + $addMarkID('c2', s); + }); + await t.sync(); + }, + }, + delete: { + $edit: path => $selectIn(path, 6, 11).removeText(), + promise: () => 'read', + }, + format: { + $edit: path => $selectIn(path, 6, 10).formatText('bold'), + promise: () => 'held', + }, + insert: { + $edit: path => $selectIn(path, 6).insertText('XY '), + promise: () => 'held', + }, + // B types a word in the line and deletes text before it (read at once, + // the word held), then C types inside B's word (S7-37). + 'insert, a delete, an insert in it': { + // A known limit (as S7-8): in a highlighted code line, A highlights its + // view of the text, without B's word, in other tokens than the stored + // ones. When A joins the line into the paragraph before while composing, + // the self-check notes a code highlight type the write left, until the + // hold ends and A reads it: then every client shows the same. + limit: (shape, end) => + shape.highlight === true && end === 'Backspace across lines', + promise: () => 'read', + run: async (t, path) => { + t.b.update(() => $selectIn(path, 6).insertText('XY ')); + t.b.update(() => $selectIn(path, 0, 2).removeText()); + await t.sync(); + t.c.update(() => $selectIn(path, 5).insertText('kc1z ')); + await t.sync(); + }, + third: true, + }, + join: { + // Delete at the end of the line: the next line joins it. + $edit: path => $block(path).selectEnd().deleteCharacter(false), + promise: () => 'read', + }, + // A comment as a MarkNode around the word: the word moves into it. + mark: { + $edit: path => + $wrapSelectionInMarkNode($selectIn(path, 6, 10), false, 'm1'), + promise: () => 'read', + }, + // Paragraphs pasted into the line, then a word typed in them (S7-28). + paste: { + $edit: path => { + $selectIn(path, 6).insertNodes([$p('pa1z pa2z'), $p('pb1z pb2z ')]); + $getSelection()!.insertText('kb9z '); + }, + promise: () => 'held', + }, + + 'paste at the start': { + $edit: path => { + $selectIn(path, 0).insertNodes([$p('pa1z pa2z'), $p('pb1z pb2z ')]); + $getSelection()!.insertText('kb9z '); + }, + // Lines made before the line are read at once; Lexical makes a list + // item pasted into at its start a paragraph, which is read with them. + promise: shape => (shape.item ? 'read' : 'held'), + }, + retype: { + $edit: path => + $setBlocksType($selectIn(path, 0), () => $createHeadingNode('h2')), + // A code line's text, code highlights, becomes plain text. + promise: shape => (shape.highlight ? 'replaced' : 'held'), + }, + split: { + $edit: path => $selectIn(path, 11).insertParagraph(), + promise: () => 'held', + }, +}; + +/** How the composition ends, or the local edit made while composing. */ +export const ENDS = [ + 'completed', + 'cancelled', + 'cut short by a blur', + 'Enter', + 'Shift+Enter', + 'Backspace across lines', + 'Delete across lines', + 'a paste', + 'a change of the line type', +] as const; + +/** The local edits of the line's structure made while composing. */ +const STRUCTURAL: ReadonlySet = new Set([ + 'Enter', + 'Shift+Enter', + 'Backspace across lines', + 'Delete across lines', + 'a paste', + 'a change of the line type', +]); + +/** The nodes of line `key`: keys, types, formats and texts. */ +function $lineShape(key: NodeKey): string { + const line = $getNodeByKey(key) as ElementNode; + const nodes: string[] = [`${line.getKey()}:${line.getType()}`]; + const walk = (node: LexicalNode) => { + nodes.push( + `${node.getKey()}:${node.getType()}:${ + $isTextNode(node) ? `${node.getFormat()}:${node.getTextContent()}` : '' + }`, + ); + if ($isElementNode(node)) { + node.getChildren().forEach(walk); + } + }; + line.getChildren().forEach(walk); + return nodes.join(' | '); +} + +/** Whether a line (an element holding no block) satisfies `test`. */ +function $hasLine(test: (line: ElementNode) => boolean): boolean { + const walk = (node: LexicalNode): boolean => + $isElementNode(node) && + ((!node.isInline() && node !== $getRoot() && test(node)) || + node.getChildren().some(walk)); + return walk($getRoot()); +} + +/** Where the composed text is: the end of `gamma` in the composed node. */ +function $composedAt(node: TextNode): number { + return node.getTextContent().lastIndexOf('gamma') + 'gamma'.length; +} + +export async function runCase( + shape: Shape, + remote: (typeof REMOTE)[string], + end: (typeof ENDS)[number], + ids: number[], +) { + const t = await setup(shape.init, { + ids: remote.third === true ? [...ids, 3] : ids, + prepare: editor => { + prepareMarkState(editor); + if (shape.highlight) { + registerCodeHighlighting(editor); + } + }, + }); + if (shape.prepare) { + await shape.prepare(t); + } + const {path} = shape; + // A starts composing at the end of "gamma". + let key: NodeKey = ''; + t.a.update(() => { + const [node, offset] = $locateIn($block(path), 16); + node.select(offset, offset); + $setCompositionKey(node.getKey()); + key = node.getKey(); + }); + let composed = ''; + const step = (next: string) => + t.a.update(() => { + const node = $getNodeByKey(key) as TextNode; + const at = $composedAt(node); + const text = node.getTextContent(); + node.setTextContent( + text.slice(0, at) + next + text.slice(at + composed.length), + ); + node.select(at + next.length, at + next.length); + composed = next; + }); + step('k'); + await t.sync(); + step('か'); + await t.sync(); + const lineKey = t.a.read(() => $block(path).getKey()); + const before = t.a.read(() => $lineShape(lineKey)); + // B changes the line A composes in. + if (remote.run !== undefined) { + await remote.run(t, path); + } else { + t.b.update(() => remote.$edit!(path)); + await t.sync(); + } + const promise = remote.promise(shape); + if (promise === 'replaced') { + // The composition goes on in the node of the new class. + key = t.a.read(() => $locateIn($block(path), 0)[0].getKey()); + } + t.a.read(() => { + const node = $getNodeByKey(key); + expect(node !== null && node.isAttached()).toBe(true); + if (promise === 'held') { + // The composed line keeps its nodes while composing. + expect($lineShape(lineKey)).toBe(before); + } + }); + if (promise === 'replaced') { + key = t.a.read(() => + $getRoot() + .getAllTextNodes() + .find(n => n.getTextContent().includes('gamma'))! + .getKey(), + ); + } + step('かん'); + await t.sync(); + if (promise === 'held') { + expect(t.a.read(() => $lineShape(lineKey))).toBe( + before.replace('gammaか', 'gammaかん'), + ); + } + // B's change as B made it, which A must show in the end. + const made = t.b.read(() => ({ + bold: $getRoot() + .getAllTextNodes() + .filter(n => n.hasFormat('bold')) + .map(n => n.getTextContent()) + .join(''), + centered: $hasLine(n => n.getFormatType() === 'center'), + commented: $getRoot() + .getAllTextNodes() + .filter(n => $getNodeMarkIDs(n)?.includes('c1') === true) + .map(n => n.getTextContent()) + .join(''), + heading: $hasLine($isHeadingNode), + marked: $getRoot() + .getAllTextNodes() + .filter(n => { + const parent = n.getParent(); + return $isMarkNode(parent) && parent.getIDs().includes('m1'); + }) + .map(n => n.getTextContent()) + .join(''), + text: $getRoot().getTextContent(), + })); + // The composition ends, or A edits the structure while composing. + t.a.update(() => { + const selection = $getSelection(); + const node = $getNodeByKey(key) as TextNode; + const line = node.getParentOrThrow(); + const at = $composedAt(node); + const text = node.getTextContent(); + if (end === 'completed') { + node.setTextContent(text.slice(0, at) + '漢' + text.slice(at + 2)); + node.select(at + 1, at + 1); + } else if (end === 'cancelled') { + node.setTextContent(text.slice(0, at) + text.slice(at + 2)); + node.select(at, at); + } else if (end === 'cut short by a blur') { + $setSelection(null); + } else if (!$isRangeSelection(selection)) { + throw new Error('no caret'); + } else if (end === 'Enter') { + selection.insertParagraph(); + } else if (end === 'Shift+Enter') { + selection.insertLineBreak(); + } else if (end === 'Backspace across lines') { + // At the start of the line: it joins the line before. + const prev = line.getPreviousSibling(); + if ($isElementNode(prev)) { + const first = line.getFirstChild(); + prev.append(...line.getChildren()); + line.remove(); + if ($isTextNode(first)) { + first.select(0, 0); + } + } + } else if (end === 'Delete across lines') { + // At the end of the line: the line after joins it. + const next = line.getNextSibling(); + if ($isElementNode(next)) { + line.append(...next.getChildren()); + next.remove(); + } + } else if (end === 'a paste') { + selection.insertNodes([$p('P1'), $p('P2')]); + } else { + $setBlocksType(selection, () => $createHeadingNode('h3')); + } + if (!STRUCTURAL.has(end)) { + $setCompositionKey(null); + } + }); + await t.sync(); + if (STRUCTURAL.has(end)) { + // The composition goes on after the structural edit, and is committed. + t.a.update(() => { + const node = $getNodeByKey(key); + if ($isTextNode(node) && node.isAttached()) { + const at = $composedAt(node); + const text = node.getTextContent(); + if (text.slice(at, at + 2) === 'かん') { + node.setTextContent(text.slice(0, at) + '漢' + text.slice(at + 2)); + node.select(at + 1, at + 1); + } + } + $setCompositionKey(null); + }); + await t.sync(); + } + await expectConverged(t.peers); + // Nothing B did is lost. + const final = t.a.read(() => $getRoot().getTextContent()); + const has = (s: string) => final.replace(/\n/g, '').includes(s); + if (made.text.includes('XY')) { + expect(has('XY')).toBe(true); + } + // Each word B or C pasted or typed shows once. + for (const word of ['pa1z', 'pb2z', 'kb9z', 'kc1z', 'XY', '日本']) { + const count = (text: string) => text.split(word).length - 1; + expect(count(final)).toBe(count(made.text)); + } + if (!made.text.includes('beta')) { + expect(has('beta')).toBe(false); + } + // Comments, marks, formats and headings: as B made them (or as the + // collaborators' edits left them, the same on every client). + const texts = (peer: Peer, keep: (node: TextNode) => boolean) => + peer.read(() => + $getRoot() + .getAllTextNodes() + .filter(keep) + .map(n => n.getTextContent()) + .join('|'), + ); + const $commented = (n: TextNode) => + $getNodeMarkIDs(n)?.includes('c1') === true; + const marked = (n: TextNode) => { + const parent = n.getParent(); + return $isMarkNode(parent) && parent.getIDs().includes('m1'); + }; + const bold = (n: TextNode) => n.hasFormat('bold'); + const inHeading = (n: TextNode) => $isHeadingNode(n.getTopLevelElement()); + for (const keep of [$commented, marked, bold, inHeading]) { + expect(texts(t.a, keep)).toBe(texts(t.b, keep)); + } + if (made.commented !== '') { + expect(texts(t.a, $commented)).toContain('beta'); + } + if (made.marked !== '') { + expect(texts(t.a, marked)).toContain('beta'); + } + if (made.bold !== '') { + expect(texts(t.a, bold)).toContain('beta'); + } + // B's alignment or type, on the composed line and nowhere else: on the + // line that holds its text (Backspace at its start makes it part of the + // line before, which keeps its own), not on the lines around it (the line + // after joins it with Delete at its end). + const joined = end === 'Backspace across lines'; + const lineOf = (word: string, test: (line: ElementNode) => boolean) => + t.a.read(() => { + const text = $getRoot() + .getAllTextNodes() + .find(n => n.getTextContent().includes(word)); + const line = + text === undefined + ? null + : $findMatchingParent(text, n => $isElementNode(n) && !n.isInline()); + return line !== null && test(line as ElementNode); + }); + for (const [made_, test] of [ + [made.heading, (line: ElementNode) => $isHeadingNode(line)], + [made.centered, (line: ElementNode) => line.getFormatType() === 'center'], + ] as const) { + if (made_) { + expect(lineOf('alpha', test)).toBe(!joined); + expect(lineOf('pre', test)).toBe(false); + if (end !== 'Delete across lines') { + expect(lineOf('omega', test)).toBe(false); + } + } + } + if (end === 'completed') { + expect(has('漢')).toBe(true); + } else if (end === 'cancelled') { + expect(final.includes('か')).toBe(false); + } +} diff --git a/packages/lexical-y/src/__tests__/utils/harness.ts b/packages/lexical-y/src/__tests__/utils/harness.ts index 7bd7a034bb0..28f31d57d4c 100644 --- a/packages/lexical-y/src/__tests__/utils/harness.ts +++ b/packages/lexical-y/src/__tests__/utils/harness.ts @@ -229,6 +229,7 @@ export class Peer { readonly nodes: Klass[] = NODES, /** Runs on the editor before the binding starts. */ readonly prepare?: (editor: LexicalEditor) => void, + readonly withBlockIds: boolean = WITH_BLOCK_IDS, ) { this.doc = new Y.Doc({gc: false}); this.doc.clientID = clientID; @@ -246,6 +247,7 @@ export class Peer { // turn the self-check on (it is off by default), so that a mismatch // fails them (expectConverged); benchmarks measure without it. selfCheck: process.env.LEXICAL_Y_BENCH !== '1', + withBlockIds, }); this.editor = bound.editor; this.binding = bound.binding; @@ -353,6 +355,7 @@ export async function setup( delivery?: Delivery; nodes?: Klass[]; prepare?: (editor: LexicalEditor) => void; + withBlockIds?: boolean; } = {}, ): Promise { const net: Network = { @@ -363,7 +366,8 @@ export async function setup( }; const ids = options.ids ?? [1, 2]; const peers = ids.map( - id => new Peer(id, net, options.nodes, options.prepare), + id => + new Peer(id, net, options.nodes, options.prepare, options.withBlockIds), ); const [a] = peers; a.update(() => { @@ -440,7 +444,13 @@ export async function freshReader( pumping: false, queue: [], }; - const peer = new Peer(99, net, nodes || from.nodes, from.prepare); + const peer = new Peer( + 99, + net, + nodes || from.nodes, + from.prepare, + from.withBlockIds, + ); Y.applyUpdate(peer.doc, Y.encodeStateAsUpdate(from.doc), 'load'); peer.binding.initialize(); await flush(); @@ -554,9 +564,10 @@ export function createBoundEditor( onError: (e: Error) => void; onWarn: (e: Error) => void; selfCheck: boolean; + withBlockIds?: boolean; }, ): {editor: LexicalEditor; binding: YBinding; start: () => () => void} { - if (!WITH_BLOCK_IDS) { + if (!(config.withBlockIds ?? WITH_BLOCK_IDS)) { const editor = createEditor({ namespace: config.namespace, nodes: [...config.nodes], diff --git a/packages/lexical-y/src/internal/Binding.ts b/packages/lexical-y/src/internal/Binding.ts index c420a8f7797..0f2ccd27867 100644 --- a/packages/lexical-y/src/internal/Binding.ts +++ b/packages/lexical-y/src/internal/Binding.ts @@ -6,6 +6,8 @@ * */ +import type {Hidden} from './Hidden'; + import { type BlockIdExtension, getPeerDependencyFromEditor, @@ -85,7 +87,7 @@ import { markFormatCloser, } from './Cursor'; import {$canonicalizeGroup, $switchesWrapper} from './Groups'; -import {Hidden, shownLength} from './Hidden'; +import {shownLength} from './Hidden'; import {$applyStoredAttrs, Reader} from './Read'; import { $isGroupNode, @@ -111,6 +113,7 @@ import {$unstorableSlotHosts, $wrapSlotShadowRootInline} from './Slots'; import { type CS, isEndItem, + isShownEnd, nameOf, prevLiveEnd, Roles, @@ -118,7 +121,7 @@ import { typeOf, type Unit, } from './State'; -import {emptyView, isGone, seenBy, type Unread} from './Unread'; +import {attrsBefore, emptyView, isGone, seenBy, type Unread} from './Unread'; import { NESTED_FALLBACK_WARNING, notesSince, @@ -835,9 +838,10 @@ export class YBinding { queueMicrotask(endOfTask(this.task)); } withPins(this._pins, () => this.onUpdate(payload)); + const hidden = this.state.hidden; if ( - this.state.hidden !== null && - this._editor._compositionKey === null + hidden !== null && + !payload.editorState.read(() => this.$holds(hidden)) ) { this.pending.add('hidden'); } @@ -1991,8 +1995,17 @@ export class YBinding { ): void { const reader = new Reader(state); setRebuilt(reader.rebuilt); - const composing = this.$composingOwner(); - reader.composingKey = composing; + // What a composition held back whose hold has ended (see $holds) shows + // first, before this change is read with the composition now going on. + if (state.hidden !== null && !this.$holds(state.hidden)) { + this.$release(state, reader); + } + reader.composingKey = this.$composingOwner(); + reader.composedAt = this.$composedCharacter(cursor); + reader.attrsBefore = (y, keys) => { + const before = attrsBefore(y, tr); + return new Map([...keys].map(key => [key, before[key]])); + }; // A container that still shows a follow-up's change the binding did not // write is read in full first: the reader changes only what the // transaction touched, on an editor it takes to show the document. @@ -2012,34 +2025,13 @@ export class YBinding { state.ids.touchedIds.add(id); } this.toSettle.clear(); + // What this change does to the line being composed is held back where + // Yjs keeps it (see Hidden); it shows when the hold ends. reader.$apply(childItems, attrKeys); - // Remote inserts into the line being composed are shown when the - // composition ends (see Hidden). - let hidden = state.hidden; - if (hidden !== null && hidden.ownerKey !== composing) { - // The composition it was held back for has ended (another may have - // started since, its own update waiting for the editor to be idle): - // what it held back shows now. - state.hidden = null; - if (!reader.$refreshComposed(hidden.ownerKey)) { - // The composed line is gone (a local edit removed or moved it): - // what was hidden in it is read where it is now (see flushHidden). - new Reader(state).$apply(this.hiddenItems(hidden), new Map()); - } - hidden = null; - } else if (hidden !== null && reader.composingRead) { - // The composed line was read in full: everything in it shows. - hidden = null; - } - if (!reader.composingRead && reader.deferred.length > 0) { - if (hidden === null) { - hidden = new Hidden(composing!); - } - for (const it of reader.deferred) { - hidden.add(it.id.client, it.id.clock, it.length); - } - } - state.hidden = hidden; + if (state.hidden !== null && !this.$holds(state.hidden)) { + // This change ended the hold (it removed the line being composed). + this.$release(state, reader); + } if (cursor !== null) { // A caret whose line the transaction replaced keeps its offset in the // line that replaced it (see $replacedLineCaret); any other selection @@ -2291,35 +2283,171 @@ export class YBinding { return cs !== undefined && cs.kind === 'i' ? ownerKey : null; } - /** Show the remote content hidden during a composition that has ended. */ + /** + * Show what a composition held back once its hold has ended (see $holds), + * in an update of the binding's own: after a local update that ended it + * (the composition ended, or moved, or a local edit took held content out + * of its line). + */ private flushHidden(): void { - const hidden = this.state.hidden; - if (hidden === null) { + if (this.state.hidden === null) { return; } const cursor = this.getCursor(); - // Cleared before the read, which shows what it held; a read that throws - // reads the whole document again (see recoverRead). - this.state.hidden = null; + let rebuilt: ReadonlySet = new Set(); + // The release clears the hold before its read, which shows what it held + // (see $release); a read that throws reads the whole document again + // (see recoverRead). this.guardedRead({ $read: () => { - const reader = new Reader(this.state); - if (!reader.$refreshComposed(hidden.ownerKey)) { - // A local edit removed the composed line: what was hidden in it is - // read where it is now (a line of its own, or joined into another). - reader.$apply(this.hiddenItems(hidden), new Map()); + const state = this.state; + if (state.hidden === null || this.$holds(state.hidden)) { + return; } + const reader = new Reader(state); + rebuilt = reader.rebuilt; + reader.composedAt = this.$composedCharacter(cursor); + this.$release(state, reader); if (cursor !== null) { this.$restoreSelection(cursor, true); } this.$caretOnText(); this.$quietSelection(); }, + // As after any remote read (see onAfterTransaction). + after: () => { + this.checkUnknownTypes(); + this.checkRead(rebuilt); + this.flushRefresh(true); + }, recover: (error, shown) => this.recoverRead(error, shown), tag: REMOTE_TAGS, }); } + /** + * The id of the character before the caret in the node being composed, + * from `cursor`, the selection captured before the change (see + * Reader#composedAt). + */ + private $composedCharacter( + cursor: YCursor | null, + ): {client: number; clock: number} | null { + const key = this._editor._compositionKey; + const selection = $getSelection(); + if ( + key === null || + cursor === null || + !$isRangeSelection(selection) || + !selection.isCollapsed() || + selection.anchor.key !== key || + selection.anchor.type !== 'text' || + selection.anchor.offset === 0 + ) { + return null; + } + const rel = createRelativePositionFromJSON(cursor.anchor); + return rel.assoc < 0 ? rel.item : null; + } + + /** + * Whether `hidden`'s hold goes on (see Hidden): the editor is composing in + * the line or inline container it holds for, and every held item is still + * in it (a local edit, as Enter or Backspace across lines, can take held + * content out of it). + */ + private $holds(hidden: Hidden): boolean { + const owner = this.$composingOwner(); + if (owner === null || owner !== hidden.ownerKey) { + return false; + } + const state = this.state; + const unit = state.unitByKey.get(owner); + const cs = unit !== undefined ? unit.cs : state.csByKey.get(owner); + const y = cs === undefined ? null : cs.y; + // Items known to be in the line: each item is walked once. + const inLine = new Set(); + for (const [parent, items] of this.hiddenItems(hidden)) { + for (const it of items) { + if (isGone(it)) { + continue; + } + if (parent !== y) { + return false; + } + if (unit === undefined) { + continue; + } + const path: Item[] = []; + let end: Item | null = it; + while (end !== null && !inLine.has(end) && !isShownEnd(end, hidden)) { + path.push(end); + end = end.right; + } + if ( + end === null + ? unit.end !== null + : !inLine.has(end) && end !== unit.end + ) { + return false; + } + for (const p of path) { + inLine.add(p); + } + } + } + return ( + hidden.marker === null || + (unit !== undefined && + [...hidden.marker.ends].every(end => end === unit.end || isGone(end))) + ); + } + + /** + * End the hold of what a composition held back (see Hidden): every held + * change is read by `reader` where its content now is, the line it was + * held in or wherever local edits took it, as a remote change that just + * arrived; nothing is held any more. + */ + private $release(state: State, reader: Reader): void { + const hidden = state.hidden; + if (hidden === null) { + return; + } + state.hidden = null; + const items = this.hiddenItems(hidden); + // A local split can inherit a held format at its new boundary. Its + // shown start was kept separate from storage (see Writer#$writeSegment): + // render that successor too, even where the stored start already agrees. + for (const [unit, {stored}] of hidden.starts) { + if ( + unit.end !== null && + state.unitByEnd.get(unit.end) === unit && + !unit.end.deleted + ) { + unit.start = stored; + const list = items.get(unit.cs.y); + if (list === undefined) { + items.set(unit.cs.y, [unit.end]); + } else { + list.push(unit.end); + } + } + } + const markers = new Map>(); + if (hidden.marker !== null) { + for (const end of hidden.marker.ends) { + if (!end.deleted) { + markers.set(typeOf(end), new Set(hidden.marker.was.keys())); + } + } + } + const composing = reader.composingKey; + reader.composingKey = null; + reader.$apply(items, markers); + reader.composingKey = composing; + } + /** The items of the binding's containers that `hidden` holds. */ private hiddenItems(hidden: Hidden): Map { const out = new Map(); @@ -4871,7 +4999,7 @@ export class YBinding { const unit = state.unitByKey.get(owner.getKey()); let start: Item | null; if (unit !== undefined && unit.end !== null) { - const left = prevLiveEnd(unit.end); + const left = prevLiveEnd(unit.end, state.hidden); start = left === null ? unit.cs.y._start : left.right; } else { const cs = state.csByKey.get(owner.getKey()); @@ -4898,7 +5026,7 @@ export class YBinding { for (let it = start; it !== null; it = it.right) { const len = shownLength(it, hidden); if (len === 0) { - if (isEndItem(it) && !isGone(it)) { + if (isShownEnd(it, hidden)) { break; } continue; @@ -4915,7 +5043,7 @@ export class YBinding { offset -= len; lastLive = it; lastLen = len; - if (isEndItem(it)) { + if (isShownEnd(it, hidden)) { break; } } @@ -4973,7 +5101,7 @@ export class YBinding { last: boolean, inside: boolean, ): LexicalNode | null => { - if (it.deleted || !isEndItem(it)) { + if (!isShownEnd(it, state.hidden)) { return null; } const unit = state.unitByEnd.get(it); @@ -5014,7 +5142,7 @@ export class YBinding { diff: number, ): {key: NodeKey; offset: number; type: 'text' | 'element'} | null { const hidden = this.state.hidden; - const liveEnd = (x: Item) => !x.deleted && isEndItem(x); + const liveEnd = (x: Item) => isShownEnd(x, hidden); let offset = 0; let it = start; for (; it !== null && it !== item && !liveEnd(it); it = it.right) { @@ -5109,7 +5237,7 @@ export class YBinding { ? null : implicitUnitAt(cs, undefined); if (implicit !== null) { - const left = prevLiveEnd(found); + const left = prevLiveEnd(found, hidden); return this.$pointInLine( implicit.key!, left === null ? (found.parent as YNode)._start : left.right, @@ -5125,7 +5253,7 @@ export class YBinding { } // Walk to the unit end; count atoms from the unit start. let end: Item | null = item; - while (end !== null && (end.deleted || !isEndItem(end))) { + while (end !== null && !isShownEnd(end, hidden)) { end = end.right; } const parentY = item.parent as YNode; @@ -5134,7 +5262,7 @@ export class YBinding { let start: Item | null; if (unit !== undefined && unit.key !== null && unit.kind === 'line') { ownerKey = unit.key; - const left = prevLiveEnd(end); + const left = prevLiveEnd(end, hidden); start = left === null ? parentY._start : left.right; } else { const cs = this.state.containers.get(parentY); @@ -5144,7 +5272,8 @@ export class YBinding { const implicit = cs !== undefined && cs.kind === 'b' ? implicitUnitAt(cs, unit) : null; if (implicit !== null) { - const left = end === null ? prevLiveEnd(item) : prevLiveEnd(end); + const left = + end === null ? prevLiveEnd(item, hidden) : prevLiveEnd(end, hidden); return this.$pointInLine( implicit.key!, left === null ? parentY._start : left.right, diff --git a/packages/lexical-y/src/internal/BlockIds.ts b/packages/lexical-y/src/internal/BlockIds.ts index 97a4b2ff59e..9e7b2c0a47c 100644 --- a/packages/lexical-y/src/internal/BlockIds.ts +++ b/packages/lexical-y/src/internal/BlockIds.ts @@ -40,7 +40,8 @@ import { import {$inNamedSlot} from './Slots'; import { type CS, - isEndItem, + isShownEnd, + shownAttrs, type State, typeOf, type Unit, @@ -492,7 +493,9 @@ export class BlockIdSettler { (u.kind === 'implicit' || (u.kind === 'line' && u.end !== null && - typeOf(u.end).getAttr(STATE_PREFIX + blockIdState.key) === undefined)) + shownAttrs(u.end, this.state.hidden)[ + STATE_PREFIX + blockIdState.key + ] === undefined)) ) { const line = $getNodeByKey(u.key); if ( @@ -1208,7 +1211,7 @@ export class BlockIdSettler { holder = $getNodeByKey(cs.key); } else { for (let it = item.right; it !== null; it = it.right) { - if (!it.deleted && isEndItem(it)) { + if (isShownEnd(it, state.hidden)) { const u = state.unitByEnd.get(it); holder = u === undefined || u.key === null ? null : $getNodeByKey(u.key); @@ -1443,7 +1446,9 @@ function $storedIdNow(state: State, node: LexicalNode): unknown { const k = STATE_PREFIX + blockIdState.key; const unit = state.unitByKey.get(key); if (unit !== undefined && unit.key === key) { - return unit.end === null ? undefined : typeOf(unit.end).getAttr(k); + return unit.end === null + ? undefined + : shownAttrs(unit.end, state.hidden)[k]; } const isGroup = $isElementNode(node) && getGroupSpec(klassOf(node)) !== null; const y = state.yByKey.get(key); @@ -1483,7 +1488,7 @@ function $storedIdNow(state: State, node: LexicalNode): unknown { if (line === undefined || line.end === null) { return state.ids.storedIdOf.get(key); } - const attrs = typeOf(line.end).getAttrs() as Attrs; + const attrs = shownAttrs(line.end, state.hidden); // Only a marker that puts its line in a group records group ids (see // Reader#$groupOf): an item whose group key concurrent edits removed is // read in a group of its own class's, which stores none. diff --git a/packages/lexical-y/src/internal/Cursor.ts b/packages/lexical-y/src/internal/Cursor.ts index 7b9b4fee172..fa44c71c708 100644 --- a/packages/lexical-y/src/internal/Cursor.ts +++ b/packages/lexical-y/src/internal/Cursor.ts @@ -270,7 +270,12 @@ export class Cursor { left: Item | null; right: Item | null; formats: Formats; - /** Remote content the editor does not show yet: not counted, not deleted. */ + /** + * Remote changes the editor holds back (see Hidden): the cursor works on + * what the editor shows, so held content is not counted or deleted, and a + * held format gives nothing a value, is never deleted, and is stepped over + * wherever the cursor looks for format items. + */ hidden: Hidden | null = null; /** * Where the run of items without live content that the cursor is in @@ -312,9 +317,19 @@ export class Cursor { } } + /** Whether `item` is a format the editor holds back (see hidden). */ + private isHeldFormat(item: Item): boolean { + return this.hidden !== null && isFormat(item) && this.hidden.hides(item); + } + /** Step over `right`, tracking formats. */ forward(): void { const item = this.right!; + if (this.isHeldFormat(item)) { + this.left = item; + this.right = item.right; + return; + } if (isLiveContent(item)) { this.gapStart = item; this.gapFormats.clear(); @@ -445,8 +460,9 @@ export class Cursor { private minimizeFormatChanges(formats: Record): void { while (this.right !== null) { const r = this.right; - if (isGone(r)) { - // a deleted item has no length: step over it + if (isGone(r) || this.isHeldFormat(r)) { + // a deleted item has no length, and a held format is not there: + // step over it } else if ( isFormat(r) && this.unitGives(r, formats) && @@ -531,7 +547,7 @@ export class Cursor { const reusedEnds: string[] = []; while (this.right !== null) { const r = this.right; - if (!isGone(r)) { + if (!isGone(r) && !this.isHeldFormat(r)) { if ( !isFormat(r) || !equalFormatValue(negated.get(keyOf(r)), valueOf(r)) @@ -825,6 +841,9 @@ export class Cursor { if (it === this.right) { leftOfCursor = false; } + if (this.isHeldFormat(it)) { + continue; + } if (it === first) { phase = 1; for (const kept of lastBefore.values()) { @@ -1152,6 +1171,10 @@ export class Cursor { (negated.size > 0 && (isGone(this.right) || isFormat(this.right)))) ) { const r = this.right; + if (this.isHeldFormat(r)) { + this.forward(); + continue; + } if (!isGone(r)) { if (isFormat(r)) { const {key, value} = r.content as ContentFormat; diff --git a/packages/lexical-y/src/internal/Hidden.ts b/packages/lexical-y/src/internal/Hidden.ts index 3e2f127c766..022de1052de 100644 --- a/packages/lexical-y/src/internal/Hidden.ts +++ b/packages/lexical-y/src/internal/Hidden.ts @@ -6,6 +6,8 @@ * */ +import type {Formats} from './Cursor'; +import type {Unit} from './State'; import type {NodeKey} from 'lexical'; import {type Item} from '@y/y'; @@ -13,20 +15,60 @@ import {type Item} from '@y/y'; import {isGone} from './Unread'; /* - * Remote content not yet shown in the editor. + * Remote changes held back while the local editor composes. * - * While the local editor is composing (IME), a remote insert into the - * composing line is applied to Yjs but not to the line's Lexical nodes: - * rewriting them would move the browser's composition. The inserted ids are - * kept here; every reader and writer of that line skips them (the line is - * the editor's view of Yjs minus these ids), and the line is read again - * when the composition ends. Items can be split or merged by Yjs, so this + * While the local editor is composing (IME) in a line or inline container, + * a remote change to it is applied to Yjs but not to its Lexical nodes: + * rewriting them would move or end the browser's composition. Everything + * Yjs keeps can be held back: the items a change inserts (text, inline + * nodes, formats and comments, a line marker that splits the line), by id, + * its deletions of items already held, and the attributes it sets on the + * line's marker. Every reader and writer of the line skips what is held (the + * line is the editor's view of Yjs minus these items, see `hides`). A change + * that removes something the editor shows (a deletion, a join) is read at + * once and whole, held items still skipped: Yjs does not keep what it + * removed, so the editor cannot go on showing it. + * + * The hold lasts while the composition goes on in that line and every held + * item is still in it: it ends when the composition ends (or is cancelled, + * or cut short by a blur), when the composition moves to another line, or + * when a local edit takes held content out of the line (Enter, Backspace + * across lines, a paste). Every held change is then read where its content + * is (see YBinding#$release). Items can be split or merged by Yjs, so this * works on id ranges, not items. */ export class Hidden { /** Sorted, merged [start, end) clock ranges per client. */ private readonly ranges = new Map(); - constructor(readonly ownerKey: NodeKey) {} + /** + * Local split units whose shown start excludes a held format inherited + * across the split. Their stored starts remain known separately, and + * release reads these units even if the stored-format cache is unchanged. + */ + readonly starts = new Map< + Unit, + {shown: Formats | null; stored: Formats | null} + >(); + /** + * The attributes of the line being composed that held changes set (only + * those this client owns, see ownsMarkerAttr and Reader#$ownedChanges): + * `was` holds the values they had before (undefined: not set), which the + * editor goes on showing, and `now` the values the changes set. `ends` are + * the markers that store them: the line's, and those of the lines a local + * split of it made while composing, which keep them too (see + * Writer#$writeMarker). + */ + marker: { + ends: Set; + was: Map; + now: Map; + } | null = null; + /** + * @param ownerKey the line or inline container composed in (a line that + * replaces it, as a change of its type does, takes it over, see + * Writer#$writeMarker) + */ + constructor(public ownerKey: NodeKey) {} /** Each hidden [start, end) clock range of `client`. */ forEachRange(fn: (client: number, start: number, end: number) => void) { @@ -57,6 +99,21 @@ export class Hidden { this.ranges.set(client, merged); } + /** Whether all of `item` is held back. */ + hides(item: Item): boolean { + const list = this.ranges.get(item.id.client); + if (list === undefined) { + return false; + } + const start = item.id.clock; + for (const [rs, re] of list) { + if (re > start) { + return rs <= start && re >= start + item.length; + } + } + return false; + } + /** * The runs of `item` (offsets within it), alternating hidden and shown: * `[length, hidden]` pairs that add up to the item's length. @@ -140,3 +197,25 @@ export function shownLength(item: Item, hidden: Hidden | null): number { } return hidden === null ? item.length : hidden.shownLength(item); } + +/** Whether the editor shows `item`: live, and not held back. */ +export function isShown(item: Item, hidden: Hidden | null): boolean { + return !isGone(item) && (hidden === null || !hidden.hides(item)); +} + +/** The part of string item `item`'s text `str` the editor shows. */ +export function shownText(str: string, item: Item, hidden: Hidden): string { + const runs = hidden.runs(item); + if (runs.length === 1) { + return runs[0][1] ? '' : str; + } + let out = ''; + let pos = 0; + for (const [len, isHidden] of runs) { + if (!isHidden) { + out += str.slice(pos, pos + len); + } + pos += len; + } + return out; +} diff --git a/packages/lexical-y/src/internal/Inline.ts b/packages/lexical-y/src/internal/Inline.ts index f67deffa64d..35ecb037949 100644 --- a/packages/lexical-y/src/internal/Inline.ts +++ b/packages/lexical-y/src/internal/Inline.ts @@ -256,7 +256,10 @@ export function $pushInline( /** * Atoms of the shared items from `start` (inclusive) up to `stop` - * (exclusive, null = end), with the formats in effect at `start`. + * (exclusive, null = end), with the formats in effect at `start`, as the + * editor shows them: without what is held back (see Hidden). Returns the + * formats stored at `stop`, held-back ones too (where a write cursor starts, + * see Cursor). */ export function readAtoms( start: Item | null, @@ -266,23 +269,38 @@ export function readAtoms( hidden: Hidden | null = null, ): Formats { const current = new Map(formats); + // The formats the editor shows: `current` without held-back ones. + const shown = hidden === null ? current : new Map(formats); let attrs: Attrs | null = null; for (let it = start; it !== null && it !== stop; it = it.right) { if (isGone(it)) { continue; } - if (hidden !== null && it.countable) { + const content = it.content; + if (content instanceof ContentFormat) { + applyFormat(current, content); + if (shown !== current && !hidden!.hides(it)) { + applyFormat(shown, content); + } + attrs = null; + continue; + } + if (!it.countable) { + continue; + } + if (attrs === null) { + attrs = Object.fromEntries(shown); + } + if (hidden !== null) { const runs = hidden.runs(it); if (runs.length > 1 || runs[0][1]) { - // Partly or wholly hidden: only shown text counts. - if (it.content instanceof ContentString) { - if (attrs === null) { - attrs = Object.fromEntries(current); - } + // Partly or wholly held back: only shown text counts (a held node + // or marker is not there). + if (content instanceof ContentString) { let pos = 0; for (const [len, isHidden] of runs) { if (!isHidden) { - pushStoredText(out, it.content.str.slice(pos, pos + len), attrs); + pushStoredText(out, content.str.slice(pos, pos + len), attrs); } pos += len; } @@ -290,22 +308,6 @@ export function readAtoms( continue; } } - const content = it.content; - if (content instanceof ContentFormat) { - if (content.value === null) { - current.delete(content.key); - } else { - current.set(content.key, content.value); - } - attrs = null; - continue; - } - if (!it.countable) { - continue; - } - if (attrs === null) { - attrs = Object.fromEntries(current); - } if (content instanceof ContentString) { pushStoredText(out, content.str, attrs); } else if (content instanceof ContentType) { @@ -328,6 +330,14 @@ export function readAtoms( return current; } +function applyFormat(formats: Formats, content: ContentFormat): void { + if (content.value === null) { + formats.delete(content.key); + } else { + formats.set(content.key, content.value); + } +} + /** * Stored text under its formats, as the reader shows it: a node type key * (`t`, with the segment key `k`) applies to the characters that type diff --git a/packages/lexical-y/src/internal/Read.ts b/packages/lexical-y/src/internal/Read.ts index 397d046f735..2a3f641528b 100644 --- a/packages/lexical-y/src/internal/Read.ts +++ b/packages/lexical-y/src/internal/Read.ts @@ -43,6 +43,7 @@ import {$appendAll, spliceAll} from './Arrays'; import {$innerBlockIdsOf, BlockIdSettler, NO_IDS} from './BlockIds'; import {type Formats} from './Cursor'; import {groupKeySig, groupSig} from './Groups'; +import {Hidden, isShown, shownText} from './Hidden'; import { $foreignFlatKeys, $groupFields, @@ -60,6 +61,7 @@ import { decodeFormatValue, eachOuterLevel, FIELD_PREFIX, + fieldsOfTypes, getContainerGroupSpec, getGroupSpec, getLineSpec, @@ -104,9 +106,11 @@ import { type CS, EMPTY_FORMATS, isEndItem, + isShownEnd, type Kind, nameOf, prevLiveEnd, + shownAttrs, type State, typeOf, type Unit, @@ -144,6 +148,8 @@ type Piece = ( tattrs: Attrs; tsig: string; wrappers: WrapperValue[]; + /** Its text holds the composition's character (see composedAt). */ + caret?: boolean; /** This text is the node being composed (see $readInline). */ composed?: boolean; } @@ -181,11 +187,17 @@ type Pool = { lines: Set; }; -/** Parse the units of a block container between two items. */ +/** + * Parse the units of a block container between two items, as the editor + * shows them: held-back content and end items (see Hidden) are not there. + * The formats each unit starts with are the stored ones, held-back formats + * too (only the line being composed is held, by its reads, see $pieces). + */ function parseRange( start: Item | null, stop: Item | null, formats: Formats, + hidden: Hidden | null, ): {parsed: Parsed[]; after: Formats} { const parsed: Parsed[] = []; const current = new Map(formats); @@ -205,7 +217,7 @@ function parseRange( } continue; } - if (!it.countable) { + if (!it.countable || (hidden !== null && hidden.hides(it))) { continue; } if (isEndItem(it)) { @@ -264,6 +276,8 @@ export class Reader { private readonly wrapperFields: (type: string) => OwnedFields | null; /** Item -> unit cache for one read. */ private located = new Map(); + /** Inline owners already read from the current document without a hold. */ + private readonly consumed = new Set(); /** Formats in effect after the content last read by $pieces. */ private lastFormats: Formats = EMPTY_FORMATS; readonly fresh = new Set(); @@ -274,15 +288,27 @@ export class Reader { private depth = 0; /** * The line or inline container the local editor is composing in (set by - * the binding). Remote inserts into it are not read (see Hidden): they - * are collected in `deferred`. `composingRead` tells that its content was - * read anyway (a structural or attribute change), so it shows everything. - * A read that only moves the line (a region read pulls it in to regroup - * its list) keeps its content, and what is hidden stays hidden. + * the binding): what a remote change does to it is held back where Yjs + * keeps it (see Hidden and $hold), and every read shows it without what + * is held. */ composingKey: NodeKey | null = null; - readonly deferred: Item[] = []; - composingRead = false; + /** + * The id of a character the composition holds, from before the read (set + * by the binding): where a read cannot keep the node being composed with + * its text, it keeps it for the text holding that character (see + * $readInline). + */ + composedAt: {client: number; clock: number} | null = null; + /** + * The values attributes `keys` of marker `y` had before the change read + * (set by the binding, see Hidden#marker). + */ + attrsBefore: + | ((y: YNode, keys: ReadonlySet) => Map) + | null = null; + /** The marker attributes the change read set (see $apply). */ + private changedAttrs: Map> = new Map(); /** Units whose marker attributes changed in this transaction. */ private readonly attrChanged = new Set(); /** Containers whose structure this read rebuilt (for the self-check). */ @@ -357,13 +383,6 @@ export class Reader { private $readFullInner(cs: CS, el: ElementNode): void { this.fresh.add(cs.y); this.rebuilt.add(cs); - if ( - this.composingKey !== null && - (cs.key === this.composingKey || - cs.units.some(u => u.key === this.composingKey)) - ) { - this.composingRead = true; - } if (cs.kind === 'i') { this.$setChildren( el, @@ -375,7 +394,12 @@ export class Reader { cs.units = []; return; } - const {parsed} = parseRange(cs.y._start, null, EMPTY_FORMATS); + const {parsed} = parseRange( + cs.y._start, + null, + EMPTY_FORMATS, + this.state.hidden, + ); const old = cs.units; const {units, tops} = this.$materialize(cs, el, parsed, old); this.$setChildren(el, tops); @@ -398,7 +422,7 @@ export class Reader { this.depth++; try { this.fresh.add(cs.y); - const {parsed} = parseRange(start, stop, formats); + const {parsed} = parseRange(start, stop, formats, this.state.hidden); const {units, tops} = this.$materialize(cs, el, parsed, []); this.$setChildren(el, tops); this.$commitUnits(cs, 0, 0, units, []); @@ -415,9 +439,12 @@ export class Reader { attrKeys: Map>, ): void { this.depth++; + const changedAttrs = this.changedAttrs; + this.changedAttrs = attrKeys; try { this.$applyInner(childItems, attrKeys); } finally { + this.changedAttrs = changedAttrs; if (--this.depth === 0) { this.settler.$settleIds(); } @@ -505,12 +532,9 @@ export class Reader { continue; } if (cs.kind === 'i') { - if (cs.key === this.composingKey && this.$defer(items)) { + if (cs.key === this.composingKey && this.$hold(null, items, false)) { continue; } - if (cs.key === this.composingKey) { - this.composingRead = true; - } this.$setChildren( el, this.$readInline(y._start, null, EMPTY_FORMATS, el), @@ -563,9 +587,6 @@ export class Reader { return; } if (pcs.kind === 'i') { - if (pcs.key === this.composingKey) { - this.composingRead = true; - } this.$setChildren( pel, this.$readInline(parent._start, null, EMPTY_FORMATS, pel), @@ -573,7 +594,7 @@ export class Reader { return; } let end: Item | null = item; - while (end !== null && (end.deleted || !isEndItem(end))) { + while (end !== null && !isShownEnd(end, this.state.hidden)) { end = end.right; } const unit = end === null ? undefined : this.state.unitByEnd.get(end); @@ -592,11 +613,20 @@ export class Reader { // ------------------------------------------------------------------------- // Block containers + /** + * The unit `item` is in: the unit of the first end item after it that + * the editor shows as one (a held-back end item is not, see Hidden). + * `flags.structural` is set when units change: the walk passes an end + * item that ends no unit and is live (inserted by this change), or + * reaches a unit whose end was deleted; the deleted end item of an + * earlier join ends nothing and is passed. + */ private $unitOf( cs: CS, item: Item, flags: {structural: boolean; tail: boolean}, ): Unit | null { + const hidden = this.state.hidden; const passed: Item[] = []; let result: Unit | null = null; let found = false; @@ -608,7 +638,7 @@ export class Reader { break; } passed.push(p); - if (isEndItem(p)) { + if (isEndItem(p) && (hidden === null || !hidden.hides(p))) { const u = this.state.unitByEnd.get(p); if (u !== undefined && u.cs === cs) { // Content before a node unit (concurrent typing next to an @@ -620,7 +650,7 @@ export class Reader { found = true; break; } - if (!p.deleted || p !== item) { + if (!p.deleted) { flags.structural = true; } } @@ -678,28 +708,35 @@ export class Reader { } } } - if (formatUnits.length > 0) { - for (const u of recomputeUnitStarts(cs, formatUnits)) { - affected.add(u); - } + const startChanged = + formatUnits.length > 0 + ? recomputeUnitStarts(cs, formatUnits) + : ([] as Unit[]); + for (const u of startChanged) { + affected.add(u); } - if ( - !flags.structural && - !flags.tail && - composingItems.length > 0 && - (attrUnits === undefined || - ![...attrUnits].some(u => u.key === this.composingKey)) && - this.$defer(composingItems) - ) { - for (const u of affected) { - if (u.key === this.composingKey) { - affected.delete(u); - } - } - if (affected.size === 0) { - return; + // The line being composed: what the change did to it is held back where + // it can be (see $hold), whatever it did to the other units; the line + // is read only for the rest, without what is held. A format reaching it + // from before (its start changed) is not its own item: read. + for (const u of affected) { + if ( + u.key !== null && + u.key === this.composingKey && + this.$hold( + u, + composingItems, + attrUnits !== undefined && attrUnits.has(u), + ) && + !startChanged.includes(u) + ) { + affected.delete(u); + this.attrChanged.delete(u); } } + if (affected.size === 0 && !flags.tail) { + return; + } if (!flags.structural && !flags.tail) { const rest = new Set(); for (const u of affected) { @@ -774,57 +811,149 @@ export class Reader { } /** - * Defer remote inserts into the composing line or inline container: only - * when all of them are inserted content (no deletion, format, marker), so - * the editor's view stays Yjs minus those ids. + * Hold back what a remote change did to the line (`unit`) or inline + * container (null) being composed (see Hidden): the items it inserted, + * those it deleted that were held back already, and, with `attrs`, the + * attributes it set on the line's marker. Returns whether all of it is + * held; the line is otherwise read for the rest, without what is held. + * A change that removes something the editor shows (Yjs does not keep + * it) is read whole, its inserts too, so the line never shows a word it + * replaced without its replacement; lines it makes before the line are + * not the line's, and are read (see $linesBefore). */ - private $defer(items: Item[]): boolean { + private $hold(unit: Unit | null, items: Item[], attrs: boolean): boolean { + const state = this.state; + const key = unit === null ? this.composingKey : unit.key; + // Releasing an invalid old hold can already have read this region from + // the integrated transaction. Its inserts are shown, not new hold-back + // candidates; the rest of the transaction must still be applied. + if (key !== null && this.consumed.has(key)) { + return false; + } + let hidden = state.hidden; for (const it of items) { - if (it.deleted || !it.countable || isEndItem(it)) { + if (it.deleted && (hidden === null || !hidden.hides(it))) { return false; } } - for (const it of items) { - this.deferred.push(it); + const before = + unit === null ? null : this.$linesBefore(unit, new Set(items)); + const held = items.filter( + it => !it.deleted && (before === null || !before.has(it)), + ); + // The marker's attributes this client owns, with the values they had + // (the line is read for lines made before it, its marker too); a change + // of attributes it does not own changes nothing it shows. + const end = unit === null || before !== null ? null : unit.end; + const changed = + attrs && end !== null && this.attrsBefore !== null + ? this.$ownedChanges(end, hidden) + : null; + if (held.length > 0 || (changed !== null && changed.size > 0)) { + if (hidden === null) { + hidden = new Hidden(this.composingKey!); + state.hidden = hidden; + } + for (const it of held) { + hidden.add(it.id.client, it.id.clock, it.length); + } + if (changed !== null && changed.size > 0) { + const marker = hidden.marker || { + ends: new Set(), + now: new Map(), + was: new Map(), + }; + const now = typeOf(end!).getAttrs() as Attrs; + for (const [k, v] of changed) { + if (!marker.was.has(k)) { + marker.was.set(k, v); + } + marker.now.set(k, now[k]); + } + marker.ends.add(end!); + hidden.marker = marker; + } } - return true; + return before === null && (!attrs || changed !== null); } - /** Read the line or inline container `key` again (composition ended). */ - $refreshComposed(key: NodeKey): boolean { - this.depth++; - try { - return this.$refreshComposedInner(key); - } finally { - if (--this.depth === 0) { - this.settler.$settleIds(); + /** + * Of the attributes of marker `end` the change read set, those this + * client owns (see ownsMarkerAttr), with the values they had: of the + * fields, those its classes of the line's type and group declare, of the + * types the change goes from and to, and of the type the editor shows (a + * held change of it). A field no class of this client declares is another + * client's, which this one neither shows nor writes. + */ + private $ownedChanges( + end: Item, + hidden: Hidden | null, + ): Map { + const y = typeOf(end); + const keys = this.changedAttrs.get(y) || new Set(); + const was = this.attrsBefore!(y, keys); + const now = y.getAttrs() as Attrs; + const shown = + hidden !== null && hidden.marker !== null && hidden.marker.ends.has(end) + ? hidden.marker.was + : null; + const valuesOf = (key: string) => [ + was.has(key) ? was.get(key) : now[key], + now[key], + ...(shown !== null && shown.has(key) ? [shown.get(key)] : []), + ]; + const editor = this.state.editor; + const fields = fieldsOfTypes(editor, valuesOf(TYPE_KEY)); + const groupFields = fieldsOfTypes( + editor, + valuesOf(GROUP_KEY).filter(g => typeof g === 'string'), + ); + const out = new Map(); + for (const [k, v] of was) { + if (ownsMarkerAttr(k, fields, groupFields)) { + out.set(k, v); } } + return out; } - private $refreshComposedInner(key: NodeKey): boolean { - const state = this.state; - const unit = state.unitByKey.get(key); - if (unit !== undefined && unit.kind === 'line') { - if (!this.$readLineInPlace(unit)) { - const i = unit.cs.units.indexOf(unit); - const el = $getNodeByKey(unit.cs.key); - if (i >= 0 && $isElementNode(el)) { - this.$readRegion(unit.cs, el, i, i, new Set([unit])); - } + /** + * Of `items` (a change to line `unit`), those that make lines of their + * own before its content: the line then starts at an end item the change + * inserted, with none of its own content before it. They do not change + * the line's nodes (a read keeps its node, matched by its marker), so + * they are read, not held (see $hold). Null when there are none. + */ + private $linesBefore(unit: Unit, items: ReadonlySet): Set | null { + const hidden = this.state.hidden; + const start = unit.end === null ? null : prevLiveEnd(unit.end, hidden); + if (start === null || !items.has(start) || start.deleted) { + return null; + } + let p: Item | null = start.left; + for (; p !== null; p = p.left) { + if (items.has(p) || !isShown(p, hidden)) { + continue; + } + if (isEndItem(p)) { + break; + } + if (p.countable) { + // The line's own content before it: a split of the line. + return null; } - return true; } - const cs = state.csByKey.get(key); - const el = $getNodeByKey(key); - if (cs !== undefined && cs.kind === 'i' && $isElementNode(el)) { - this.$setChildren( - el, - this.$readInline(cs.y._start, null, EMPTY_FORMATS, el), - ); - return true; + const out = new Set(); + for ( + let it: Item | null = p === null ? unit.cs.y._start : p.right; + it !== null && it !== start.right; + it = it.right + ) { + if (items.has(it)) { + out.add(it); + } } - return false; + return out; } private $readRegion( @@ -883,12 +1012,17 @@ export class Reader { i < len ? units[i].start || EMPTY_FORMATS : formatsAfterUnit(units, i - 1); - const {parsed, after} = parseRange(startItem, stop, startFormats); + const {parsed, after} = parseRange( + startItem, + stop, + startFormats, + this.state.hidden, + ); // Groups next to the region may merge with it. const isGroupItem = (p: Parsed | undefined) => p !== undefined && p.kind === 'line' && - this.$groupOf(cs, markerAttrs(typeOf(p.end!))) !== null; + this.$groupOf(cs, shownMarkerAttrs(p.end!, this.state.hidden)) !== null; // A region that reads back empty (its units were removed) can leave // two groups adjacent: read them together so they regroup as one when // their keys are equal. @@ -1044,7 +1178,8 @@ export class Reader { if (!$isElementNode(line) || u.end === null || u.end.deleted) { return false; } - let attrs = markerAttrs(typeOf(u.end)); + const hidden = this.state.hidden; + let attrs = shownMarkerAttrs(u.end, hidden); if (attrs[TYPE_KEY] !== line.getType()) { return false; } @@ -1053,33 +1188,30 @@ export class Reader { if (lineGroupSig(attrs, group) !== u.sig) { return false; } - if (u.key === this.composingKey) { - this.composingRead = true; - } this.$applyLineAttrs( line, attrs, this.$derivedFor(u.cs, group), this.attrChanged.has(u), ); - const left = prevLiveEnd(u.end); + const left = prevLiveEnd(u.end, hidden); + const first = left === null ? u.cs.y._start : left.right; this.$setChildren( line, - this.$readInline( - left === null ? u.cs.y._start : left.right, - u.end, - u.start || EMPTY_FORMATS, - line, - ), + this.$readInline(first, u.end, u.start || EMPTY_FORMATS, line), ); // Whether it keeps the id it derives, as a line placed anew does (see // $commitUnits): the attributes just applied can change that (a quote // that comes to hold paragraphs is no identified block in a cell). this.settler.$placeUnit(u); - // A format that now reaches past the marker changes the next unit. - const after = this.lastFormats; + // A format that now reaches past the marker changes the next unit: the + // formats stored there, held-back ones too (the next line is not held). + const after = + hidden === null + ? this.lastFormats + : formatsAfter(first, u.end, u.start || EMPTY_FORMATS); let nextEnd: Item | null = u.end.right; - while (nextEnd !== null && (nextEnd.deleted || !isEndItem(nextEnd))) { + while (nextEnd !== null && !isShownEnd(nextEnd, hidden)) { nextEnd = nextEnd.right; } const next = @@ -1229,11 +1361,6 @@ export class Reader { // the browser's composition stays in its DOM. let composedLine: {into: Unit; key: NodeKey} | null = null; const ck = this.composingKey; - // Whether the line being composed is among the units read, and whether - // it kept its range and content (untouched below): only then does what - // is hidden in it stay hidden (see composingRead). - const composedOld = ck !== null && old.some(u => u.key === ck); - let composedKept = false; if (ck !== null && this.state.editor._compositionKey !== null) { const k = old.findIndex(u => u.key === ck); if (k >= 0 && old[k].end !== null && old[k].end!.deleted) { @@ -1338,7 +1465,7 @@ export class Reader { const containerGroup = this.$containerGroupSpec(cs); let attrs: Attrs = p.kind === 'line' - ? markerAttrs(typeOf(p.end!)) + ? shownMarkerAttrs(p.end!, state.hidden) : { [TYPE_KEY]: containerGroup === null @@ -1401,9 +1528,6 @@ export class Reader { $isElementNode(existing) ? existing : null, derived, ); - if (untouched && prevUnit !== undefined && prevUnit.key === ck) { - composedKept = true; - } state.ids.noteStoredId(line.getKey(), storedId); if (!untouched) { this.$setChildren( @@ -1513,9 +1637,6 @@ export class Reader { } k = m; } - if (composedOld && !composedKept) { - this.composingRead = true; - } return {tops, units: entries.map(e => e.unit)}; } @@ -2079,8 +2200,10 @@ export class Reader { wrappers = wrappersFromAttrs(all, this.wrapperFields); } }; + // As the editor shows it: without what is held back (see Hidden). + const hidden = this.state.hidden; for (let it = start; it !== null && it !== stop; it = it.right) { - if (it.deleted) { + if (!isShown(it, hidden)) { continue; } const content = it.content; @@ -2098,12 +2221,18 @@ export class Reader { } snapshot(); if (content instanceof ContentString) { - const parts = content.str.split('\n'); - parts.forEach((part, i) => { + const str = + hidden === null ? content.str : shownText(content.str, it, hidden); + const caret = this.caretIn(it, hidden); + let pos = 0; + str.split('\n').forEach((part, i) => { if (i > 0) { pieces.push({item: it, kind: 'br', tattrs: tattrs!, wrappers}); + pos++; } for (const run of tabRuns(part, tattrs!, tsig)) { + const holds = caret >= pos && caret < pos + run.text.length; + pos += run.text.length; const last = pieces[pieces.length - 1]; if ( last !== undefined && @@ -2112,8 +2241,10 @@ export class Reader { sameWrappers(last.wrappers, wrappers) ) { last.text += run.text; + last.caret = last.caret === true || holds; } else { pieces.push({ + caret: holds, item: it, kind: 'text', tattrs: run.tattrs, @@ -2135,6 +2266,38 @@ export class Reader { return pieces; } + /** + * Where the composition's character (see composedAt) is in the text the + * editor shows of string item `it`, or -1. + */ + private caretIn(it: Item, hidden: Hidden | null): number { + const at = this.composedAt; + if ( + at === null || + at.client !== it.id.client || + at.clock < it.id.clock || + at.clock >= it.id.clock + it.length + ) { + return -1; + } + const offset = at.clock - it.id.clock; + if (hidden === null) { + return offset; + } + let pos = 0; + let shown = 0; + for (const [len, isHidden] of hidden.runs(it)) { + if (offset < pos + len) { + return isHidden ? -1 : shown + offset - pos; + } + if (!isHidden) { + shown += len; + } + pos += len; + } + return -1; + } + /** * Lexical inline nodes for the content between two items, reusing the * nodes currently under `el` (and `extraPool`) where possible. @@ -2147,6 +2310,9 @@ export class Reader { extraPool?: LexicalNode[], ): LexicalNode[] { const state = this.state; + if (el !== null && state.hidden === null) { + this.consumed.add(el.getKey()); + } // Whether `el` (a list item) holds a nested group first before the read: // the wrappers built below can move that child into a link before `el`'s // children are set (see the end). @@ -2244,12 +2410,12 @@ export class Reader { let composedPlaced = false; if (composed !== null) { const ct = composed.getTextContent(); + const sameType = (piece: Piece): piece is Piece & {kind: 'text'} => + piece.kind === 'text' && + (piece.tattrs[TYPE_KEY] ?? 'text') === composed.getType(); for (let k = 0; ct !== '' && k < pieces.length; k++) { const piece = pieces[k]; - if ( - piece.kind !== 'text' || - (piece.tattrs[TYPE_KEY] ?? 'text') !== composed.getType() - ) { + if (!sameType(piece)) { continue; } const at = piece.text.indexOf(ct); @@ -2268,6 +2434,16 @@ export class Reader { composedPlaced = true; break; } + // Its text is not there whole (a collaborator deleted part of it, or + // put an inline node in it): it keeps its key for the text that holds + // the composition, so the browser goes on composing in its DOM node. + for (let k = 0; !composedPlaced && k < pieces.length; k++) { + const piece = pieces[k]; + if (sameType(piece) && piece.caret === true) { + pieces[k] = {...piece, composed: true}; + composedPlaced = true; + } + } } // Reading nested inline nodes reads their own content: keep this // sequence's end formats for the caller. @@ -2382,9 +2558,14 @@ export class Reader { // As text of a type it does not register. () => $createTextNode(piece.text), ); - if (segment !== null) { - state.setSegment(node.getKey(), segment); - } + } + // The node shows this piece, so it writes the piece's segment key + // (the composed node keeps its key for a piece whose stored segment + // a remote change made another). + if (segment !== null) { + state.setSegment(node.getKey(), segment); + } else { + state.segments.delete(node.getKey()); } push(node); } else if (piece.kind === 'br') { @@ -2884,6 +3065,28 @@ function keyFromFields(fields: Attrs): Attrs { return out; } +/** + * The formats in effect after the live items from `start` up to `stop` + * (exclusive), from `formats` in effect at `start`. + */ +function formatsAfter( + start: Item | null, + stop: Item | null, + formats: Formats, +): Formats { + const out = new Map(formats); + for (let it = start; it !== null && it !== stop; it = it.right) { + if (!it.deleted && it.content instanceof ContentFormat) { + if (it.content.value === null) { + out.delete(it.content.key); + } else { + out.set(it.content.key, it.content.value); + } + } + } + return out; +} + /** * Set the start formats of the units after the first of `from` from the * shared items, until they agree again past the last of `from`; returns the @@ -2996,9 +3199,12 @@ function formatsAfterUnit(units: Unit[], k: number): Formats { return formats; } -/** A marker's attributes; a marker without a type is a paragraph's. */ -function markerAttrs(y: YNode): Attrs { - const attrs = y.getAttrs() as Attrs; +/** + * Marker `end`'s attributes as the editor shows them (see shownAttrs); a + * marker without a type is a paragraph's. + */ +function shownMarkerAttrs(end: Item, hidden: Hidden | null): Attrs { + const attrs = shownAttrs(end, hidden); if (typeof attrs[TYPE_KEY] !== 'string') { attrs[TYPE_KEY] = 'paragraph'; } diff --git a/packages/lexical-y/src/internal/Schema.ts b/packages/lexical-y/src/internal/Schema.ts index 2d232c601f6..da5d7f9b89f 100644 --- a/packages/lexical-y/src/internal/Schema.ts +++ b/packages/lexical-y/src/internal/Schema.ts @@ -1748,6 +1748,27 @@ function jsonFieldsOf(klass: Klass): ReadonlySet { return fields; } +/** + * The fields a client owns where the type is one of `types` (an absent one + * is a paragraph's): those its classes of them declare in their `$config` + * schema (see ownedFieldsOf), none of a type it does not register. + */ +export function fieldsOfTypes( + editor: LexicalEditor, + types: readonly unknown[], +): OwnedFields { + const sets: OwnedFields[] = []; + for (const type of types) { + const info = editor._nodes.get( + typeof type === 'string' ? type : 'paragraph', + ); + if (info !== undefined) { + sets.push(ownedFieldsOf(info.klass, false)); + } + } + return {has: field => sets.some(set => set.has(field))}; +} + const itemFieldSets = new WeakMap(); /** diff --git a/packages/lexical-y/src/internal/SelfCheck.ts b/packages/lexical-y/src/internal/SelfCheck.ts index c885f3cd2bc..3c0c515ddb0 100644 --- a/packages/lexical-y/src/internal/SelfCheck.ts +++ b/packages/lexical-y/src/internal/SelfCheck.ts @@ -169,7 +169,7 @@ export function $checkWrite(writer: Writer): string[] { get: key => writer.state.segments.get(key), }; $pushInline(want, nodes, ctx); - const left = prevLiveEnd(end); + const left = prevLiveEnd(end, writer.state.hidden); const have = new Atoms(); readAtoms( left === null ? unit.cs.y._start : left.right, @@ -251,7 +251,15 @@ export function $checkWrite(writer: Writer): string[] { } } } - if (d.kind === 'line') { + // (Not a marker whose attributes are held back: the line shows its old + // ones until the hold ends, see Hidden.) + const hidden = writer.state.hidden; + if ( + d.kind === 'line' && + (hidden === null || + hidden.marker === null || + !hidden.marker.ends.has(end)) + ) { const attrs = (end.content as ContentType).type.getAttrs() as Record< string, unknown diff --git a/packages/lexical-y/src/internal/State.ts b/packages/lexical-y/src/internal/State.ts index f0d6be0fdd3..953d1446d1d 100644 --- a/packages/lexical-y/src/internal/State.ts +++ b/packages/lexical-y/src/internal/State.ts @@ -31,6 +31,7 @@ import { $isLine, $isWrapper, $nestedGroupOf, + type Attrs, BLOCK_PREFIX, getGroupSpec, getLineSpec, @@ -118,10 +119,43 @@ export function isEndItem(it: Item): boolean { ); } -/** The previous live end item before `it` in its container, or null. */ -export function prevLiveEnd(it: Item | null): Item | null { +/** + * Whether `it` ends a unit as the editor shows its container: a live end + * item that is not held back (see Hidden). + */ +export function isShownEnd(it: Item, hidden: Hidden | null): boolean { + return !isGone(it) && isEndItem(it) && (hidden === null || !hidden.hides(it)); +} + +/** + * Marker `end`'s attributes as the editor shows them: those a held change + * set have the values they had (see Hidden#marker). + */ +export function shownAttrs(end: Item, hidden: Hidden | null): Attrs { + const attrs = typeOf(end).getAttrs() as Attrs; + const held = hidden === null ? null : hidden.marker; + if (held !== null && held.ends.has(end)) { + for (const [k, v] of held.was) { + if (v === undefined) { + delete attrs[k]; + } else { + attrs[k] = v; + } + } + } + return attrs; +} + +/** + * The end item before `it` in its container that the editor shows (see + * isShownEnd), or null. + */ +export function prevLiveEnd( + it: Item | null, + hidden: Hidden | null, +): Item | null { for (let p = it === null ? null : it.left; p !== null; p = p.left) { - if (!isGone(p) && isEndItem(p)) { + if (isShownEnd(p, hidden)) { return p; } } diff --git a/packages/lexical-y/src/internal/Write.ts b/packages/lexical-y/src/internal/Write.ts index 3c631ae24e1..d52800eb807 100644 --- a/packages/lexical-y/src/internal/Write.ts +++ b/packages/lexical-y/src/internal/Write.ts @@ -100,8 +100,8 @@ import {$writeSlots} from './Slots'; import { type CS, EMPTY_FORMATS, - isEndItem, isPartKey, + isShownEnd, nameOf, prevLiveEnd, type Roles, @@ -2187,13 +2187,21 @@ export class Writer { /** * Set every unit's start formats (the formats in effect where it starts) * from the shared items: deletes and inserts start from them, and a stale - * value would leave a format open or close one early. + * value would leave a format open or close one early. A locally split + * unit still covered by a hold keeps its shown start until release, with + * the stored start recorded separately (see Hidden#starts). */ private $recomputeStarts(cs: CS): void { const formats = new Map(); let it: Item | null = cs.y._start; for (const u of cs.units) { u.start = formats.size > 0 ? new Map(formats) : null; + const hidden = this.state.hidden; + const held = hidden === null ? undefined : hidden.starts.get(u); + if (held !== undefined) { + held.stored = u.start; + u.start = held.shown; + } if (u.end === null) { break; } @@ -2222,7 +2230,7 @@ export class Writer { if (unit.end === null || isGone(unit.end)) { return; } - const left = prevLiveEnd(unit.end); + const left = prevLiveEnd(unit.end, this.state.hidden); const formats = new Map(unit.start || EMPTY_FORMATS); for ( let it: Item | null = left === null ? unit.cs.y._start : left.right; @@ -2242,7 +2250,7 @@ export class Writer { } } let next: Item | null = unit.end.right; - while (next !== null && (isGone(next) || !isEndItem(next))) { + while (next !== null && !isShownEnd(next, this.state.hidden)) { next = next.right; } const nextUnit: Unit | undefined = @@ -2458,7 +2466,7 @@ export class Writer { deleteItem(u.end, this.tr); } } else { - const left = unitLeft(old, idx); + const left = unitLeft(old, idx, this.state.hidden); const stop = unitStop(old, idx); const startItem = left === null ? u.cs.y._start : left.right; const cursor = this.cursor(u.cs.y, left, u.start || EMPTY_FORMATS); @@ -2505,6 +2513,12 @@ export class Writer { remaining.push(oi); } const lines = newSeg.filter(di => desired[di].kind !== 'node'); + const hidden = this.state.hidden; + const heldSegment = + hidden !== null && + remaining.some( + oi => old[oi].key === hidden.ownerKey || hidden.starts.has(old[oi]), + ); // The formats in effect at `left`, where the content is diffed from: the // first remaining unit's start, also when node units before it were // deleted above (see $deleteUnit). @@ -2512,6 +2526,17 @@ export class Writer { remaining.length > 0 ? old[remaining[0]].start || EMPTY_FORMATS : this.formatsAfter(left); + // A previously split successor already has distinct inherited starts. + // Keep its shown start for the diff/cursor, but seed raw scans from its + // stored start: the held opener may be before this segment's first item. + const inherited = + hidden === null || remaining.length === 0 + ? undefined + : hidden.starts.get(old[remaining[0]]); + const storedStartFormats = + inherited === undefined + ? startFormats + : inherited.stored || EMPTY_FORMATS; const startItem = left === null ? cs.y._start : left.right; const lineItems = new Map(); if (remaining.length > 0 || lines.length > 0) { @@ -2633,6 +2658,8 @@ export class Writer { after = end; } // Formats in effect at each unit start (a format can span a marker). + // New halves of the held line inherit the shown lane, not evidence that + // they already rendered a held opener in an earlier half. if (lines.length > 0) { const byEnd = new Map(); const segUnits = newUnits.slice(newUnits.length - newSeg.length); @@ -2641,8 +2668,10 @@ export class Writer { byEnd.set(u.end, u); } } - const current = new Map(startFormats); + const current = new Map(storedStartFormats); + const shown = new Map(startFormats); let pendingStart: Formats = new Map(current); + let pendingShown: Formats = new Map(shown); let k = 0; for ( let it = startItem; @@ -2653,17 +2682,23 @@ export class Writer { continue; } if (it.content instanceof ContentFormat) { - if (it.content.value === null) { - current.delete(it.content.key); - } else { - current.set(it.content.key, it.content.value); + applyFormat(current, it.content); + if (hidden === null || !hidden.hides(it)) { + applyFormat(shown, it.content); } continue; } const u = byEnd.get(it); if (u !== undefined) { u.start = pendingStart.size > 0 ? pendingStart : null; + if (heldSegment && !sameFormatMaps(pendingStart, pendingShown)) { + hidden!.starts.set(u, { + shown: pendingShown.size > 0 ? pendingShown : null, + stored: u.start, + }); + } pendingStart = new Map(current); + pendingShown = new Map(shown); k++; } } @@ -2675,7 +2710,7 @@ export class Writer { // Reader#$readLineInPlace and $fixStartsAfter): before, its start took // them up to its end item, so a closer an outside writer left in it // could be judged redundant and deleted. - const formats = new Map(startFormats); + const formats = new Map(storedStartFormats); let it: Item | null = startItem; if (after !== left) { for (; it !== null; it = it.right) { @@ -2689,10 +2724,7 @@ export class Writer { } } for (; it !== null; it = it.right) { - if (isGone(it)) { - continue; - } - if (it.countable && isEndItem(it)) { + if (isShownEnd(it, this.state.hidden)) { const next = this.state.unitByEnd.get(it); if ( next !== undefined && @@ -2721,7 +2753,7 @@ export class Writer { return EMPTY_FORMATS; } const unit = this.state.unitByEnd.get(item); - const start = prevLiveEnd(item); + const start = prevLiveEnd(item, this.state.hidden); const atoms = new Atoms(); return readAtoms( start === null ? (item.parent as YNode)._start : start.right, @@ -2929,11 +2961,11 @@ export class Writer { let left: Item | null; let stop: Item | null; if (u.end !== null) { - left = prevLiveEnd(u.end); + left = prevLiveEnd(u.end, this.state.hidden); stop = u.end.right; } else { const idx = old.indexOf(u); - left = unitLeft(old, idx); + left = unitLeft(old, idx, this.state.hidden); stop = unitStop(old, idx); } const atoms = new Atoms(); @@ -3150,7 +3182,7 @@ export class Writer { ): void { const c = this.cursor( u.cs.y, - prevLiveEnd(u.end!), + prevLiveEnd(u.end!, this.state.hidden), u.start || EMPTY_FORMATS, ); c.advance(offset); @@ -3944,8 +3976,47 @@ export class Writer { /** Write the marker attributes of desired line `d` on marker `item`. */ private $writeMarker(item: Item, d: Desired): void { - const desired = this.$marker(d); + let desired = this.$marker(d); const line = d.node; + const hidden = this.state.hidden; + const held = hidden === null ? null : hidden.marker; + if ( + held !== null && + line !== null && + ((held.ends.has(item) && !this.prevState._nodeMap.has(line.getKey())) || + this.$isComposedLine(line)) + ) { + // Attributes of the composed line a collaborator changed are held + // back (see Hidden#marker): one this edit left as the editor shows it + // keeps the collaborator's value; one it changed takes its value, and + // is no longer held. So does the line a split of it makes while + // composing (the composed line ends at a new marker, the new line + // holding the rest of it at the old one). A line it was joined into + // keeps its own, though it ends at the old marker; an empty line + // Enter makes at its end has the attributes the editor shows. + const composed = this.$isComposedLine(line); + desired = {...desired}; + for (const [k, was] of [...held.was]) { + if (equalValue(desired[k], was)) { + const now = held.now.get(k); + if (now === undefined) { + delete desired[k]; + } else { + desired[k] = now; + } + } else if (composed) { + held.was.delete(k); + held.now.delete(k); + } + } + held.ends.add(item); + if (composed) { + hidden!.ownerKey = line.getKey(); + } + if (held.was.size === 0) { + hidden!.marker = null; + } + } const y = typeOf(item); const current = attrsOf(y) as Attrs; const storedText = $isElementNode(line) && !$holdsText(line); @@ -3994,6 +4065,33 @@ export class Writer { } } + /** + * Whether `line` is the line being composed that a held change is for + * (see Hidden): that line, or a line this edit made to replace it (a + * change of its type) holding the composition, not one it was joined + * into. + */ + private $isComposedLine(line: LexicalNode): boolean { + const ownerKey = this.state.hidden!.ownerKey; + if (line.getKey() === ownerKey) { + return true; + } + const owner = $getNodeByKey(ownerKey); + if ( + (owner !== null && owner.isAttached()) || + this.prevState._nodeMap.has(line.getKey()) + ) { + return false; + } + const key = this.state.editor._compositionKey; + const composed = key === null ? null : $getNodeByKey(key); + return ( + composed !== null && + $isElementNode(line) && + (composed.is(line) || line.isParentOf(composed)) + ); + } + /** * Whether this write leaves attribute `key` as stored although the editor * has another value: a follow-up (no one's edit) does not write an @@ -4799,14 +4897,18 @@ function wrapperOnly(attrs: Attrs): Attrs { } /** The live end item before an old unit (after earlier deletions too). */ -function unitLeft(units: Unit[], idx: number): Item | null { +function unitLeft( + units: Unit[], + idx: number, + hidden: Hidden | null, +): Item | null { const u = units[idx]; if (u.end !== null) { - return prevLiveEnd(u.end); + return prevLiveEnd(u.end, hidden); } let e = idx > 0 ? units[idx - 1].end : null; while (e !== null && isGone(e)) { - e = prevLiveEnd(e); + e = prevLiveEnd(e, hidden); } return e; }