Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lexical-y-reorder-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lexical/y': patch
---

[fix] Write a block reorder as a move of the block Lexical moved, so that a collaborator's concurrent typing and caret in a block it passes stay in that block

Verify: concurrent edits around a dragged block. The blocks it passes keep a collaborator's typing and caret. Text typed in the moved block itself at the same time is lost or lands at its old position, and undoing the move takes back what collaborators typed in the moved block since (the Moves limit in the collaboration docs); this now holds also when the moved block is larger than the blocks it passes.
19 changes: 16 additions & 3 deletions packages/lexical-website/docs/collaboration/y.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,8 +713,18 @@ not merge as a user might expect:

- **Moves.** Moving a block is a delete and a re-insert, so text a
collaborator types into the moved block at the same time is lost or lands
at its old position, and two collaborators moving the same block at once
can duplicate it. Lexical operations that move content (turning a
at its old position, a collaborator's caret in it stays at that position,
and two collaborators moving the same block at once can duplicate it.
Undoing the move restores the block as it was before the move: what
collaborators typed in it since is lost or, in a moved line, stays where
the moved copy was. The moved block is the node Lexical recorded as moved:
an existing node passed to `insertBefore`, `insertAfter`, `append` or
`splice` in the update (as a block drag does), even one put back where it
was. The blocks it passes that Lexical did not record keep what
collaborators type in them. Between recorded blocks, and in a reorder that
Lexical did not record (an editor state set whole), the binding rewrites
as few blocks as it can, the smaller ones on a tie.
Lexical operations that move content (turning a
paragraph into a list inside a container, indenting list items that hold
blocks) behave the same way, and so does the first edit that restructures
a list stored in an older shape (a nested list kept as a node inside an
Expand Down Expand Up @@ -899,7 +909,10 @@ not merge as a user might expect:
inside the earlier deleted text after that, where its format changed
more than once along it, takes the format that text had at its start,
and so does that text where an undo brings it back after another
collaborator's delete there.
collaborator's delete there. Two joins at once into the same line both
write its type, and Yjs keeps one of them by client id: a heading joined
across a quote to the paragraph after it, while a collaborator joins that
paragraph into the quote, can become a quote.

## Changing the layout and migrating

Expand Down
55 changes: 49 additions & 6 deletions packages/lexical-y/src/__tests__/unit/BlocksAndLines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ describe('@lexical/y moves', () => {
$create(ColTitleNode).append($createTextNode('t')),
$create(ColContentNode).append($p('c1'), $p('c2')),
);
test(`a collapsible dragged past a box ‖ typing inside it keeps the typing (ids ${ids})`, async () => {
test(`a collapsible dragged past a box ‖ typing in the box keeps the typing (ids ${ids})`, async () => {
const t = await setup(
() =>
$getRoot().append(
Expand All @@ -361,27 +361,70 @@ describe('@lexical/y moves', () => {
);
t.offline();
t.a.update(() => $block([1]).insertAfter($block([0])));
t.b.update(() => $selectIn([0, 1, 0], 2).insertText('TYPED'));
t.b.update(() => $selectIn([1, 0], 3).insertText('TYPED'));
await t.sync();
await expectConverged(t.peers);
expect(t.a.describe()).toContain('c1TYPED');
expect(t.a.describe()).toContain('boxTYPED');
});

test(`moving a paragraph down one slot ‖ typing in it keeps the typing in it (ids ${ids})`, async () => {
// Moves limit (y.md): the dragged block is the one deleted and written
// again, so typing inside it at the same time is lost.
test.fails(
`a collapsible dragged past a box ‖ typing inside it keeps the typing (ids ${ids})`,
async () => {
const t = await setup(
() =>
$getRoot().append(
$col(),
$createBoxNode('o').append($p('box')),
$p('end'),
),
{ids},
);
t.offline();
t.a.update(() => $block([1]).insertAfter($block([0])));
t.b.update(() => $selectIn([0, 1, 0], 2).insertText('TYPED'));
await t.sync();
await expectConverged(t.peers);
expect(t.a.describe()).toContain('c1TYPED');
},
);

test(`moving a paragraph down one slot ‖ typing in the paragraph it passed stays there (ids ${ids})`, async () => {
const t = await setup(
() => $getRoot().append($p('Revenue grew 4% in Q3.'), $p('Second.')),
{ids},
);
t.offline();
t.a.update(() => $selectIn([0], 13).insertText('by '));
t.a.update(() => $selectIn([1], 6).insertText(' line'));
t.b.update(() => $block([1]).insertAfter($block([0])));
await t.sync();
await expectConverged(
t.peers,
'paragraph("Second.") / paragraph("Revenue grew by 4% in Q3.")',
'paragraph("Second line.") / paragraph("Revenue grew 4% in Q3.")',
);
});

// Moves limit (y.md): the moved paragraph is the one deleted and written
// again, so typing in it at the same time lands at its old place.
test.fails(
`moving a paragraph down one slot ‖ typing in it keeps the typing in it (ids ${ids})`,
async () => {
const t = await setup(
() => $getRoot().append($p('Revenue grew 4% in Q3.'), $p('Second.')),
{ids},
);
t.offline();
t.a.update(() => $selectIn([0], 13).insertText('by '));
t.b.update(() => $block([1]).insertAfter($block([0])));
await t.sync();
await expectConverged(
t.peers,
'paragraph("Second.") / paragraph("Revenue grew by 4% in Q3.")',
);
},
);

test(`between equal blocks, the moved one is rewritten: typing in the other is kept (ids ${ids})`, async () => {
const t = await setup(
() => $getRoot().append($p('one'), $p('two'), $p('three')),
Expand Down
113 changes: 113 additions & 0 deletions packages/lexical-y/src/__tests__/unit/regressions/Joins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* 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 joins. A line is stored as its text followed by its
// marker, which holds its type, so a join deletes the earlier line's marker
// and writes the earlier line's type onto the later line's marker, which now
// ends the joined line. Each case is named after its finding.

import {$createCodeNode} from '@lexical/code';
import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
import {
$createRangeSelection,
$createTextNode,
$getRoot,
$setSelection,
type ElementNode,
type TextNode,
} from 'lexical';
import {describe, expect, test} from 'vitest';

import {$p, expectConverged, type Setup, setup} from '../../utils/harness';

/** Delete from the end of block `from` to the start of block `to`. */
function $joinBlocks(from: number, to: number) {
const blocks = $getRoot().getChildren() as ElementNode[];
const end = blocks[from].getLastChild() as TextNode;
const start = blocks[to].getFirstChild() as TextNode;
const selection = $createRangeSelection();
selection.anchor.set(end.getKey(), end.getTextContentSize(), 'text');
selection.focus.set(start.getKey(), 0, 'text');
$setSelection(selection);
selection.removeText();
}

// S7-22: two joins at once into the same line. A deletes from the end of a
// heading (or code block) to the start of the paragraph after the quote that
// follows it; B joins that paragraph into the quote (Backspace at its
// start). Both write the type of the line that ends at the paragraph's
// marker, A the heading's and B the quote's, and Yjs keeps one of the two
// writes by client id: when it keeps B's, the heading becomes a quote on
// every client although neither edit retyped it. Open: the type is one
// value on that shared marker, so the two writes cannot both be kept;
// agreeing on the earlier line's type would need each join to record which
// line it came from and a follow-up to elect one (y.md, Limits, Other
// races).
describe('S7-22: two joins at once into the same line', () => {
const FIRST: [string, string, () => ElementNode][] = [
[
'heading',
'heading',
() => $createHeadingNode('h1').append($createTextNode('Title t44z')),
],
[
'code block',
'code',
() => $createCodeNode().append($createTextNode('const t44z = 1;')),
],
];
for (const [kind, type, $first] of FIRST) {
const run = async (ids: number[]): Promise<Setup> => {
const t = await setup(
() =>
$getRoot().append(
$first(),
$createQuoteNode().append($createTextNode('t48z')),
$p('t49z t50z'),
),
{ids},
);
t.offline();
t.a.update(() => $joinBlocks(0, 2));
t.b.update(() => $joinBlocks(1, 2));
await t.sync();
return t;
};
const firstType = (t: Setup) =>
t.a.read(() => $getRoot().getFirstChildOrThrow().getType());

for (const ids of [
[1, 2],
[2, 1],
]) {
test(`S7-22 ${kind}: converges, with the text of both joins (ids ${ids})`, async () => {
const t = await run(ids);
await expectConverged(t.peers);
expect(t.a.read(() => $getRoot().getTextContent())).not.toContain(
't48z',
);
expect(t.a.read(() => $getRoot().getChildrenSize())).toBe(1);
});
}

test(`S7-22 ${kind}: the joined line keeps the ${kind}'s type when the client that joined the ${kind} has the higher id`, async () => {
const t = await run([2, 1]);
expect(firstType(t)).toBe(type);
});

test.fails(
`S7-22 ${kind}: the joined line keeps the ${kind}'s type when the client that joined the quote has the higher id`,
async () => {
const t = await run([1, 2]);
expect(firstType(t)).toBe(type);
},
);
}
});
Loading
Loading