diff --git a/src/document/crdt/tree.ts b/src/document/crdt/tree.ts index 17084947d..d456908ab 100644 --- a/src/document/crdt/tree.ts +++ b/src/document/crdt/tree.ts @@ -696,6 +696,7 @@ export class CRDTTree extends CRDTGCElement { }); const toBeRemoveds: Array = []; + const toBeMovedToFromParents: Array = []; const latestCreatedAtMap = new Map(); this.traverseInPosRange( @@ -704,12 +705,32 @@ export class CRDTTree extends CRDTGCElement { toParent, toLeft, (node, contain) => { - // If node is a element node and half-contained in the range, - // it should not be removed. - if (!node.isText && contain != TagContained.All) { + // NOTE(hackerwins): If the node overlaps as a closing tag with the + // range then we need to keep the node. + if (!node.isText && contain == TagContained.Closing) { return; } + // NOTE(hackerwins): If the node overlaps as an opening tag with the + // range then we need to move the remaining children to fromParent. + if (!node.isText && contain == TagContained.Opening) { + // TODO(hackerwins): Define more clearly merge-able rules + // between two parents. For now, we only merge two parents are + // both element nodes having text children. + // e.g.

a|b

c|d

->

a|d

+ if (!fromParent.hasTextChild() || !toParent.hasTextChild()) { + return; + } + + for (const child of node.children) { + if (toBeRemoveds.includes(child)) { + continue; + } + + toBeMovedToFromParents.push(child); + } + } + const actorID = node.getCreatedAt().getActorID()!; const latestCreatedAt = latestCreatedAtMapByActor ? latestCreatedAtMapByActor!.has(actorID!) @@ -732,12 +753,15 @@ export class CRDTTree extends CRDTGCElement { for (const node of toBeRemoveds) { node.remove(editedAt); - if (node.isRemoved) { this.removedNodeMap.set(node.id.toIDString(), node); } } + for (const node of toBeMovedToFromParents) { + fromParent.append(node); + } + // 03. insert the given node at the given position. if (contents?.length) { let leftInChildren = fromLeft; // tree diff --git a/test/integration/tree_test.ts b/test/integration/tree_test.ts index d73eed329..caa2119be 100644 --- a/test/integration/tree_test.ts +++ b/test/integration/tree_test.ts @@ -963,13 +963,7 @@ describe('Tree.edit', function () { ); doc.update((root) => root.t.edit(2, 18)); - assert.equal( - doc.getRoot().t.toXML(), - /*html*/ `

a

f

`, - ); - - // TODO(sejongk): Use the below assertion after implementing Tree.Move. - // assert.equal(doc.getRoot().t.toXML(), /*html*/ `

af

`); + assert.equal(doc.getRoot().t.toXML(), /*html*/ `

af

`); }); }); diff --git a/test/unit/document/crdt/tree_test.ts b/test/unit/document/crdt/tree_test.ts index 87c771f30..3b506b9e4 100644 --- a/test/unit/document/crdt/tree_test.ts +++ b/test/unit/document/crdt/tree_test.ts @@ -91,7 +91,7 @@ describe('CRDTTreeNode', function () { }); // NOTE: To see the XML string as highlighted, install es6-string-html plugin in VSCode. -describe('CRDTTree', function () { +describe('CRDTTree.Edit', function () { it('Can inserts nodes with edit', function () { // 0 // @@ -250,7 +250,7 @@ describe('CRDTTree', function () { }); }); -describe.skip('Tree.split', function () { +describe.skip('CRDTTree.Split', function () { it('Can split text nodes', function () { // 00. Create a tree with 2 paragraphs. // 0 1 6 11 @@ -417,7 +417,7 @@ describe.skip('Tree.split', function () { }); }); -describe('Tree.move', function () { +describe('CRDTTree.Merge', function () { it('Can delete nodes between element nodes with edit', function () { // 01. Create a tree with 2 paragraphs. // 0 1 2 3 4 5 6 7 8 @@ -440,27 +440,58 @@ describe('Tree.move', function () { ); assert.deepEqual(tree.toXML(), /*html*/ `

ab

cd

`); - // 02. delete b, c and first paragraph. + // 02. delete b, c and the second paragraph. // 0 1 2 3 4 //

a d

tree.editByIndex([2, 6], undefined, issueTime()); - assert.deepEqual(tree.toXML(), /*html*/ `

a

d

`); - // TODO(sejongk): Use the below assertion after implementing Tree.Move. - // assert.deepEqual(tree.toXML(), /*html*/ `

ad

`); - - // const treeNode = tree.toTestTreeNode(); - // assert.equal(treeNode.size, 4); // root - // assert.equal(treeNode.children![0].size, 2); // p - // assert.equal(treeNode.children![0].children![0].size, 1); // a - // assert.equal(treeNode.children![0].children![1].size, 1); // d - - // // 03. insert a new text node at the start of the first paragraph. - // tree.editByIndex( - // [1, 1], - // [new CRDTTreeNode(issuePos(), 'text', '@')], - // issueTime(), - // ); - // assert.deepEqual(tree.toXML(), /*html*/ `

@ad

`); + assert.deepEqual(tree.toXML(), /*html*/ `

ad

`); + + const node = tree.toTestTreeNode(); + assert.equal(node.size, 4); // root + assert.equal(node.children![0].size, 2); // p + assert.equal(node.children![0].children![0].size, 1); // a + assert.equal(node.children![0].children![1].size, 1); // d + + // 03. insert a new text node at the start of the first paragraph. + tree.editByIndex( + [1, 1], + [new CRDTTreeNode(issuePos(), 'text', '@')], + issueTime(), + ); + assert.deepEqual(tree.toXML(), /*html*/ `

@ad

`); + }); + + it('Can delete nodes between elements in different level with edit', function () { + // 01. Create a tree with 2 paragraphs. + // 0 1 2 3 4 5 6 7 8 9 10 + //

a b

c d

+ const tree = new CRDTTree( + new CRDTTreeNode(issuePos(), 'root'), + issueTime(), + ); + tree.editByIndex([0, 0], [new CRDTTreeNode(issuePos(), 'p')], issueTime()); + tree.editByIndex([1, 1], [new CRDTTreeNode(issuePos(), 'b')], issueTime()); + tree.editByIndex( + [2, 2], + [new CRDTTreeNode(issuePos(), 'text', 'ab')], + issueTime(), + ); + tree.editByIndex([6, 6], [new CRDTTreeNode(issuePos(), 'p')], issueTime()); + tree.editByIndex( + [7, 7], + [new CRDTTreeNode(issuePos(), 'text', 'cd')], + issueTime(), + ); + assert.deepEqual( + tree.toXML(), + /*html*/ `

ab

cd

`, + ); + + // 02. delete b, c and second paragraph. + // 0 1 2 3 4 5 + //

a d + tree.editByIndex([3, 8], undefined, issueTime()); + assert.deepEqual(tree.toXML(), /*html*/ `

ad

`); }); it.skip('Can merge different levels with edit', function () {