From 98346720977c6fa4e4491b436346893df8d1ae40 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 2 Dec 2025 01:21:45 +0900 Subject: [PATCH 01/20] Define Text reverse operation --- .../sdk/src/document/crdt/rga_tree_split.ts | 71 ++++++++++++++++++- packages/sdk/src/document/crdt/text.ts | 43 ++++++++--- .../src/document/operation/edit_operation.ts | 51 +++++++++++-- 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/packages/sdk/src/document/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index 979a35e0d..1ae8df0d3 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -597,14 +597,20 @@ export class RGATreeSplit implements GCParent { * @param range - range of RGATreeSplitNode * @param editedAt - edited time * @param value - value - * @returns `[RGATreeSplitPos, Array, Array]` + * @returns `[RGATreeSplitPos, Array, DataSize, Array>, Array]` */ public edit( range: RGATreeSplitPosRange, editedAt: TimeTicket, value?: T, versionVector?: VersionVector, - ): [RGATreeSplitPos, Array, DataSize, Array>] { + ): [ + RGATreeSplitPos, + Array, + DataSize, + Array>, + Array, + ] { const diff = { data: 0, meta: 0 }; // 01. split nodes with from and to @@ -659,11 +665,13 @@ export class RGATreeSplit implements GCParent { // 04. add removed node const pairs: Array = []; + const removedValues: Array = []; for (const [, removedNode] of removedNodes) { pairs.push({ parent: this, child: removedNode }); + removedValues.push(removedNode.getValue()); } - return [caretPos, pairs, diff, changes]; + return [caretPos, pairs, diff, changes, removedValues]; } /** @@ -787,6 +795,63 @@ export class RGATreeSplit implements GCParent { return clone; } + /** + * `normalizePos` converts a local position `(id, rel)` into a single + * absolute offset measured from the head `(0:0)` of the physical chain. + */ + public normalizePos(pos: RGATreeSplitPos): RGATreeSplitPos { + const index = this.posToIndex(pos, false); + + return RGATreeSplitPos.of(this.getHead().getID(), index); + } + + /** + * `refinePos` remaps the given pos to the current split chain. + * + * - Traverses the physical `next` chain (not `insNext`). + * - Counts only live characters: removed nodes are treated as length 0. + * - If the given offset exceeds the length of the current node, + * it moves forward through `next` nodes, subtracting lengths, + * until the offset fits in a live node. + * - If it runs out of nodes, it snaps to the end of the last node. + * + * Example: + * Before split: ["12345"](1:2:0), pos = (1:2:0, rel=5) + * After split : ["1"](1:2:0) - ["23"](1:2:1) - ["45"](1:2:3) + * refinePos(pos) -> (1:2:3, rel=2) + * + * Example: + * ["12"](1:2:0, live) and pos = (1:2:0, rel=4) + * refinePos(pos) -> points two chars after "12", + * i.e. advances into following nodes, skipping removed ones. + */ + public refinePos(pos: RGATreeSplitPos): RGATreeSplitPos { + let node = this.findFloorNode(pos.getID()); + if (!node) { + throw new YorkieError( + Code.ErrInvalidArgument, + `the node of the given id should be found: ${pos.getID().toTestString()}`, + ); + } + + let offsetInPart = pos.getRelativeOffset(); + let partLen = node.getContentLength(); + + while (offsetInPart > partLen) { + offsetInPart -= partLen; + const next: RGATreeSplitNode | undefined = node!.getNext(); + + if (!next) { + return RGATreeSplitPos.of(node.getID(), partLen); + } + + node = next; + partLen = node.getLength(); + } + + return RGATreeSplitPos.of(node.getID(), offsetInPart); + } + /** * `toTestString` returns a String containing the meta data of the node * for debugging purpose. diff --git a/packages/sdk/src/document/crdt/text.ts b/packages/sdk/src/document/crdt/text.ts index a962cd54d..6750dd163 100644 --- a/packages/sdk/src/document/crdt/text.ts +++ b/packages/sdk/src/document/crdt/text.ts @@ -26,6 +26,7 @@ import { RGATreeSplit, RGATreeSplitNode, RGATreeSplitNodeID, + RGATreeSplitPos, RGATreeSplitPosRange, ValueChange, } from '@yorkie-js/sdk/src/document/crdt/rga_tree_split'; @@ -239,7 +240,13 @@ export class CRDTText extends CRDTElement { editedAt: TimeTicket, attributes?: Record, versionVector?: VersionVector, - ): [Array>, Array, DataSize, RGATreeSplitPosRange] { + ): [ + Array>, + Array, + DataSize, + RGATreeSplitPosRange, + Array, + ] { const crdtTextValue = content ? CRDTTextValue.create(content) : undefined; if (crdtTextValue && attributes) { for (const [k, v] of Object.entries(attributes)) { @@ -247,12 +254,8 @@ export class CRDTText extends CRDTElement { } } - const [caretPos, pairs, diff, valueChanges] = this.rgaTreeSplit.edit( - range, - editedAt, - crdtTextValue, - versionVector, - ); + const [caretPos, pairs, diff, valueChanges, removedValues] = + this.rgaTreeSplit.edit(range, editedAt, crdtTextValue, versionVector); const changes: Array> = valueChanges.map((change) => ({ ...change, @@ -268,7 +271,7 @@ export class CRDTText extends CRDTElement { type: TextChangeType.Content, })); - return [changes, pairs, diff, [caretPos, caretPos]]; + return [changes, pairs, diff, [caretPos, caretPos], removedValues]; } /** @@ -392,6 +395,20 @@ export class CRDTText extends CRDTElement { return this.rgaTreeSplit.getTreeByID(); } + /** + * `refinePos` refines the given RGATreeSplitPos. + */ + public refinePos(pos: RGATreeSplitPos): RGATreeSplitPos { + return this.rgaTreeSplit.refinePos(pos); + } + + /** + * `normalizePos` normalizes the given RGATreeSplitPos. + */ + public normalizePos(pos: RGATreeSplitPos): RGATreeSplitPos { + return this.rgaTreeSplit.normalizePos(pos); + } + /** * `getDataSize` returns the data usage of this element. */ @@ -507,6 +524,16 @@ export class CRDTText extends CRDTElement { return this.rgaTreeSplit.findIndexesFromRange(range); } + /** + * `posToIndex` converts the given position to index. + */ + public posToIndex( + pos: RGATreeSplitPos, + preferToLeft: boolean = false, + ): number { + return this.rgaTreeSplit.posToIndex(pos, preferToLeft); + } + /** * `getGCPairs` returns the pairs of GC. */ diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index 3e151a05a..abec921fb 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -18,7 +18,7 @@ import { TimeTicket } from '@yorkie-js/sdk/src/document/time/ticket'; import { VersionVector } from '@yorkie-js/sdk/src/document/time/version_vector'; import { CRDTRoot } from '@yorkie-js/sdk/src/document/crdt/root'; import { RGATreeSplitPos } from '@yorkie-js/sdk/src/document/crdt/rga_tree_split'; -import { CRDTText } from '@yorkie-js/sdk/src/document/crdt/text'; +import { CRDTText, CRDTTextValue } from '@yorkie-js/sdk/src/document/crdt/text'; import { Operation, OpInfo, @@ -37,6 +37,7 @@ export class EditOperation extends Operation { private toPos: RGATreeSplitPos; private content: string; private attributes: Map; + private isUndoOp: boolean | undefined; constructor( parentCreatedAt: TimeTicket, @@ -44,13 +45,15 @@ export class EditOperation extends Operation { toPos: RGATreeSplitPos, content: string, attributes: Map, - executedAt: TimeTicket, + executedAt?: TimeTicket, + isUndoOp?: boolean, ) { super(parentCreatedAt, executedAt); this.fromPos = fromPos; this.toPos = toPos; this.content = content; this.attributes = attributes; + this.isUndoOp = isUndoOp; } /** @@ -62,7 +65,8 @@ export class EditOperation extends Operation { toPos: RGATreeSplitPos, content: string, attributes: Map, - executedAt: TimeTicket, + executedAt?: TimeTicket, + isUndoOp?: boolean, ): EditOperation { return new EditOperation( parentCreatedAt, @@ -71,6 +75,7 @@ export class EditOperation extends Operation { content, attributes, executedAt, + isUndoOp, ); } @@ -97,7 +102,7 @@ export class EditOperation extends Operation { } const text = parentObject as CRDTText; - const [changes, pairs, diff] = text.edit( + const [changes, pairs, diff, , removedValues] = text.edit( [this.fromPos, this.toPos], this.content, this.getExecutedAt(), @@ -105,6 +110,10 @@ export class EditOperation extends Operation { versionVector, ); + const reverseOp = this.toReverseOperation( + removedValues, + text.normalizePos(this.fromPos), + ); root.acc(diff); for (const pair of pairs) { @@ -121,9 +130,43 @@ export class EditOperation extends Operation { path: root.createPath(this.getParentCreatedAt()), } as OpInfo; }), + reverseOp, }; } + private toReverseOperation( + removedValues: Array, + normalizedFromPos: RGATreeSplitPos, + ): Operation { + // 1) Content + const restoredContent = + removedValues && removedValues.length !== 0 + ? removedValues.map((v) => v.getContent()).join('') + : ''; + + // 2) Attribute + let restoredAttrs: Array<[string, string]> | undefined; + if (removedValues.length === 1) { + const attrsObj = removedValues[0].getAttributes(); + if (attrsObj) { + restoredAttrs = Array.from(Object.entries(attrsObj as any)); + } + } + + return EditOperation.create( + this.getParentCreatedAt(), + normalizedFromPos, + RGATreeSplitPos.of( + normalizedFromPos.getID(), + normalizedFromPos.getRelativeOffset() + (this.content.length ?? 0), + ), + restoredContent, + restoredAttrs ? new Map(restoredAttrs) : new Map(), + undefined, + true, + ); + } + /** * `getEffectedCreatedAt` returns the creation time of the effected element. */ From b7f0e7efdc7439ba4e576e678545ae08b374cbe7 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 2 Dec 2025 01:48:39 +0900 Subject: [PATCH 02/20] Define undo/redo execution and reconcile operation when receive remote edit --- packages/sdk/src/document/document.ts | 12 +++ packages/sdk/src/document/history.ts | 27 ++++++ .../src/document/operation/edit_operation.ts | 86 +++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index 6316e1f6a..c55c6b1e8 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -83,6 +83,7 @@ import { Rule } from '@yorkie-js/schema'; import { validateYorkieRuleset } from '@yorkie-js/sdk/src/document/schema/ruleset_validator'; import { setupDevtools } from '@yorkie-js/sdk/src/devtools'; import * as Devtools from '@yorkie-js/sdk/src/devtools/types'; +import { EditOperation } from './operation/edit_operation'; /** * `DocumentOptions` are the options to create a new document. @@ -1444,6 +1445,17 @@ export class Document< } const { opInfos } = change.execute(this.root, this.presences, source); + for (const op of opInfos) { + if (op instanceof EditOperation) { + const [rangeFrom, rangeTo] = op.normalizePos(this.root); + this.internalHistory.reconcileTextEdit( + op.getParentCreatedAt(), + rangeFrom, + rangeTo, + op.getContent()?.length ?? 0, + ); + } + } this.changeID = this.changeID.syncClocks(change.getID()); if (opInfos.length) { const rawChange = this.isEnableDevtools() ? change.toStruct() : undefined; diff --git a/packages/sdk/src/document/history.ts b/packages/sdk/src/document/history.ts index 832d77467..c429d2b91 100644 --- a/packages/sdk/src/document/history.ts +++ b/packages/sdk/src/document/history.ts @@ -20,6 +20,7 @@ import { RemoveOperation } from './operation/remove_operation'; import { MoveOperation } from './operation/move_operation'; import { AddOperation } from './operation/add_operation'; import { TimeTicket } from '../yorkie'; +import { EditOperation } from './operation/edit_operation'; /** * `HistoryOperation` is a type of history operation. @@ -150,4 +151,30 @@ export class History

{ replace(this.undoStack); replace(this.redoStack); } + + /** + * `reconcileTextEdit` reconciles the text edit operation. + * Scan both undo/redo stacks and replace the edit operation with the new position. + */ + public reconcileTextEdit( + parentCreatedAt: TimeTicket, + rangeFrom: number, + rangeTo: number, + contentLength: number, + ): void { + const replace = (stack: Array>>) => { + for (const ops of stack) { + for (const op of ops) { + if ( + op instanceof EditOperation && + op.getParentCreatedAt().compare(parentCreatedAt) === 0 + ) { + op.reconcileOperation(rangeFrom, rangeTo, contentLength); + } + } + } + }; + replace(this.undoStack); + replace(this.redoStack); + } } diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index abec921fb..f2ee007a1 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -167,6 +167,92 @@ export class EditOperation extends Operation { ); } + /** + * `normalizePos` normalizes the position of the edit operation. + */ + public normalizePos(root: CRDTRoot): [number, number] { + const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); + + if (!parentObject) { + throw new YorkieError( + Code.ErrInvalidArgument, + `fail to find ${this.getParentCreatedAt()}`, + ); + } + + if (!(parentObject instanceof CRDTText)) { + throw new YorkieError( + Code.ErrInvalidArgument, + `only Text can normalize edit`, + ); + } + + const text = parentObject as CRDTText; + const rangeFrom = text.normalizePos(this.fromPos).getRelativeOffset(); + const rangeTo = text.normalizePos(this.toPos).getRelativeOffset(); + + return [rangeFrom, rangeTo]; + } + + /** + * `reconcileOperation` reconciles the edit operation with the new position. + */ + public reconcileOperation( + rangeFrom: number, + rangeTo: number, + contentLength: number, + ): void { + if (!this.isUndoOp) { + return; + } + if (!Number.isInteger(rangeFrom) || !Number.isInteger(rangeTo)) { + return; + } + if (rangeFrom > rangeTo) { + return; + } + + const rangeLen = rangeTo - rangeFrom; + const a = this.fromPos.getRelativeOffset(); + const b = this.toPos.getRelativeOffset(); + + const apply = (na: number, nb: number) => { + this.fromPos = RGATreeSplitPos.of(this.fromPos.getID(), Math.max(0, na)); + this.toPos = RGATreeSplitPos.of(this.toPos.getID(), Math.max(0, nb)); + }; + + // Does not overlap + if (rangeTo <= a) { + apply(a - rangeLen + contentLength, b - rangeLen + contentLength); + return; + } + if (b <= rangeFrom) { + return; + } + + // Fully overlap: contains + if (rangeFrom <= a && b <= rangeTo && rangeFrom !== rangeTo) { + apply(rangeFrom, rangeFrom); + return; + } + if (a <= rangeFrom && rangeTo <= b && a !== b) { + apply(a, b - rangeLen + contentLength); + return; + } + + // overlap at the start + if (rangeFrom < a && a < rangeTo && rangeTo < b) { + apply(rangeFrom, rangeFrom + (b - rangeTo)); + return; + } + + // overlap at the end + if (a < rangeFrom && rangeFrom < b && b < rangeTo) { + apply(a, rangeFrom); + return; + } + } + /** * `getEffectedCreatedAt` returns the creation time of the effected element. */ From 5e734b54b619605407c984339fce1698131790a3 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 2 Dec 2025 02:31:24 +0900 Subject: [PATCH 03/20] Add TC for Text undo/redo --- .../sdk/test/integration/history_text_test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/sdk/test/integration/history_text_test.ts diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts new file mode 100644 index 000000000..0948952d5 --- /dev/null +++ b/packages/sdk/test/integration/history_text_test.ts @@ -0,0 +1,121 @@ +import { describe, it, assert } from 'vitest'; +import { Document, Text } from '@yorkie-js/sdk/src/yorkie'; + +type TextOp = 'insert' | 'delete' | 'replace' | 'style'; +const ops: Array = ['insert', 'delete', 'replace']; + +/** + * Operation Set 1 + */ +function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { + doc.update((root) => { + const t = root.t; + + switch (op) { + case 'insert': { + const len = t.length ?? t.toString().length; + t.edit(len, len, 'X'); + break; + } + case 'delete': { + const len = t.length ?? t.toString().length; + if (len >= 3) { + t.edit(1, 2, ''); // del [1,2) + } else if (len > 0) { + t.edit(0, 1, ''); + } + break; + } + case 'replace': { + // [1,3) → '12' + const len = t.length ?? t.toString().length; + if (len >= 3) { + t.edit(1, 3, '12'); + } else { + const to = Math.min(1, len); + t.edit(0, to, 'R'); + } + break; + } + case 'style': { + const len = t.length ?? t.toString().length; + if (len === 0) { + t.edit(0, 0, 'A'); + } + const end = t.length ?? t.toString().length; + t.setStyle(0, end, { bold: true }); + break; + } + } + }, op); +} + +describe('Text Undo - single op', () => { + for (const op of ['insert', 'delete', 'replace'] as Array) { + it(`should undo ${op}`, () => { + const doc = new Document<{ t: Text }>('test-doc'); + // initialize + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + + // undo + applyTextOp1(doc, op); + + doc.history.undo(); + + assert.equal( + doc.getRoot().t.toString(), + 'The fox jumped.', + `undo ${op} should restore text content`, + ); + }); + } +}); + +describe.skip('Text Undo - chained ops', () => { + // read the text content + const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); + + for (const op1 of ops) { + for (const op2 of ops) { + for (const op3 of ops) { + const caseName = `${op1}-${op2}-${op3}`; + + it(`should step back correctly: ${caseName}`, () => { + const doc = new Document<{ t: Text }>('test-doc'); + + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + // 텍스트 스냅샷 저장 + const S: Array = []; + S.push(contentOf(doc)); // S0 + + applyTextOp1(doc, op1); + S.push(contentOf(doc)); // S1 + + applyTextOp1(doc, op2); + S.push(contentOf(doc)); // S2 + + applyTextOp1(doc, op3); + S.push(contentOf(doc)); // S3 + + // S3 -> S2 -> S1 -> S0 + for (let i = 3; i >= 1; i--) { + doc.history.undo(); + const back = contentOf(doc); + assert.equal( + back, + S[i - 1], + `undo back to S${i - 1} failed on ${caseName}`, + ); + } + }); + } + } + } +}); From 157271b99751a633e889ee5df2ea2d48f18b8c68 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 2 Dec 2025 02:32:21 +0900 Subject: [PATCH 04/20] Add TODO for style and multi-client test --- packages/sdk/test/integration/history_text_test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index 0948952d5..f31d7d40e 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -3,6 +3,7 @@ import { Document, Text } from '@yorkie-js/sdk/src/yorkie'; type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace']; +// TODO(JOOHOJANG): We need to add 'style' operation and multi-client test /** * Operation Set 1 From 3f7add749dff1f5d982a69cacd7be6efb8404c62 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 2 Dec 2025 02:32:48 +0900 Subject: [PATCH 05/20] Use index value when normalizing --- packages/sdk/src/document/crdt/rga_tree_split.ts | 2 +- packages/sdk/src/document/operation/edit_operation.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/document/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index 1ae8df0d3..fa6a814cd 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -800,7 +800,7 @@ export class RGATreeSplit implements GCParent { * absolute offset measured from the head `(0:0)` of the physical chain. */ public normalizePos(pos: RGATreeSplitPos): RGATreeSplitPos { - const index = this.posToIndex(pos, false); + const index = this.posToIndex(pos, true); return RGATreeSplitPos.of(this.getHead().getID(), index); } diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index f2ee007a1..f39dc9f89 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -102,6 +102,12 @@ export class EditOperation extends Operation { } const text = parentObject as CRDTText; + + if (this.isUndoOp) { + this.fromPos = text.refinePos(this.fromPos); + this.toPos = text.refinePos(this.toPos); + } + const [changes, pairs, diff, , removedValues] = text.edit( [this.fromPos, this.toPos], this.content, @@ -146,6 +152,7 @@ export class EditOperation extends Operation { // 2) Attribute let restoredAttrs: Array<[string, string]> | undefined; + if (removedValues.length === 1) { const attrsObj = removedValues[0].getAttributes(); if (attrsObj) { From 1981f7457c5b2d3ec09b1a7b7baccd7ec5f762d7 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 2 Dec 2025 14:57:15 +0900 Subject: [PATCH 06/20] Add optional chaining --- packages/sdk/src/document/operation/edit_operation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index f39dc9f89..10549976b 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -165,7 +165,7 @@ export class EditOperation extends Operation { normalizedFromPos, RGATreeSplitPos.of( normalizedFromPos.getID(), - normalizedFromPos.getRelativeOffset() + (this.content.length ?? 0), + normalizedFromPos.getRelativeOffset() + (this.content?.length ?? 0), ), restoredContent, restoredAttrs ? new Map(restoredAttrs) : new Map(), From d6211b35f4d7597b4c05b0a856ba4c57710c5abb Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Wed, 3 Dec 2025 11:21:24 +0900 Subject: [PATCH 07/20] Add TC for style --- packages/sdk/src/document/document.ts | 6 +- .../sdk/test/integration/history_text_test.ts | 162 +++++++++++++++++- 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index c55c6b1e8..d85c2888d 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -1447,11 +1447,11 @@ export class Document< const { opInfos } = change.execute(this.root, this.presences, source); for (const op of opInfos) { if (op instanceof EditOperation) { - const [rangeFrom, rangeTo] = op.normalizePos(this.root); + const [from, to] = op.normalizePos(this.root); this.internalHistory.reconcileTextEdit( op.getParentCreatedAt(), - rangeFrom, - rangeTo, + from, + to, op.getContent()?.length ?? 0, ); } diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index f31d7d40e..1d7badcc8 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -1,10 +1,10 @@ import { describe, it, assert } from 'vitest'; import { Document, Text } from '@yorkie-js/sdk/src/yorkie'; +import { withTwoClientsAndDocuments } from '@yorkie-js/sdk/test/integration/integration_helper'; type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace']; // TODO(JOOHOJANG): We need to add 'style' operation and multi-client test - /** * Operation Set 1 */ @@ -51,6 +51,33 @@ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { }, op); } +/** + * Operation Set 2 + */ +function applyTextOp2(doc: Document<{ t: Text }>, op: TextOp) { + doc.update((root) => { + const t = root.t; + + switch (op) { + case 'insert': { + t.edit(0, 0, 'Q'); + break; + } + case 'delete': { + const len = t.length ?? t.toString().length; + if (len > 0) t.edit(len - 1, len, ''); + break; + } + case 'replace': { + const len = t.length ?? t.toString().length; + if (len > 0) t.edit(0, 1, 'Z'); + else t.edit(0, 0, 'Z'); + break; + } + } + }, op); +} + describe('Text Undo - single op', () => { for (const op of ['insert', 'delete', 'replace'] as Array) { it(`should undo ${op}`, () => { @@ -63,7 +90,6 @@ describe('Text Undo - single op', () => { // undo applyTextOp1(doc, op); - doc.history.undo(); assert.equal( @@ -73,9 +99,35 @@ describe('Text Undo - single op', () => { ); }); } + + // TODO(JOOHOJANG): We need to test this after implementing style operation + it.skip('should undo/redo style op', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + + const initialJSON = doc.toSortedJSON(); + const styledJSON = + '{"t":[{"attrs":{"bold":true},"val":"The fox jumped."}]}'; + + applyTextOp1(doc, 'style'); + assert.equal( + doc.toSortedJSON(), + styledJSON, + 'style op should add formatting', + ); + + doc.history.undo(); + assert.equal(doc.toSortedJSON(), initialJSON, 'undo should drop style'); + + doc.history.redo(); + assert.equal(doc.toSortedJSON(), styledJSON, 'redo should reapply style'); + }); }); -describe.skip('Text Undo - chained ops', () => { +describe('Text Undo - chained ops', () => { // read the text content const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); @@ -120,3 +172,107 @@ describe.skip('Text Undo - chained ops', () => { } } }); + +describe('Text Undo - multi client', () => { + for (const op1 of ops) { + for (const op2 of ops) { + const caseName = `${op1}-${op2}`; + + it(`should converge after both undo: ${caseName}`, async function ({ + task, + }) { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + await c1.sync(); + await c2.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + applyTextOp1(d1, op1); + applyTextOp2(d2, op2); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal( + d1.toSortedJSON(), + d2.toSortedJSON(), + 'Mismatch after both ops', + ); + + d1.history.undo(); + d2.history.undo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + assert.equal( + d1.toSortedJSON(), + d2.toSortedJSON(), + 'Mismatch after both undos', + ); + }, task.name); + }); + } + } + + // TODO(JOOHOJANG): We need to test this after implementing style operation + it.skip('should keep convergence when both clients style/undo/redo', async function ({ + task, + }) { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + const initialSetup = () => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + }; + + initialSetup(); + await c1.sync(); + await c2.sync(); + const initialJSON = '{"t":[{"val":"The fox jumped."}]}'; + assert.equal(d1.toSortedJSON(), initialJSON); + assert.equal(d2.toSortedJSON(), initialJSON); + + d1.update((root) => { + root.t.setStyle(0, 15, { bold: true }); + }, 'style bold by c1'); + d2.update((root) => { + root.t.setStyle(4, 15, { italic: true }); + }, 'style italic by c2'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + const styledJSON = + '{"t":[{"attrs":{"bold":true},"val":"The "},{"attrs":{"bold":true,"italic":true},"val":"fox jumped."}]}'; + assert.equal(d1.toSortedJSON(), styledJSON, 'Mismatch after style ops'); + assert.equal(d2.toSortedJSON(), styledJSON, 'Mismatch after style ops'); + + d1.history.undo(); + d2.history.undo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), initialJSON, 'Mismatch after style undo'); + assert.equal(d2.toSortedJSON(), initialJSON, 'Mismatch after style undo'); + + d1.history.redo(); + d2.history.redo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), styledJSON, 'Mismatch after style redo'); + assert.equal(d2.toSortedJSON(), styledJSON, 'Mismatch after style redo'); + }, task.name); + }); +}); From fdb81f2323be76867331865a7bd824a58286082f Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Wed, 3 Dec 2025 16:07:23 +0900 Subject: [PATCH 08/20] Test test undo/redo in codemirror example --- examples/vanilla-codemirror6/package.json | 1 + examples/vanilla-codemirror6/src/main.ts | 89 ++++++++++++------- .../sdk/test/integration/history_text_test.ts | 1 + pnpm-lock.yaml | 5 +- 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/examples/vanilla-codemirror6/package.json b/examples/vanilla-codemirror6/package.json index c47e7012c..28b9721cb 100644 --- a/examples/vanilla-codemirror6/package.json +++ b/examples/vanilla-codemirror6/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.4.1", "@yorkie-js/sdk": "workspace:*", "codemirror": "^6.0.2" } diff --git a/examples/vanilla-codemirror6/src/main.ts b/examples/vanilla-codemirror6/src/main.ts index 262f8d275..15ab9991a 100644 --- a/examples/vanilla-codemirror6/src/main.ts +++ b/examples/vanilla-codemirror6/src/main.ts @@ -2,6 +2,7 @@ import type { EditOpInfo, OpInfo } from '@yorkie-js/sdk'; import yorkie, { DocEventType } from '@yorkie-js/sdk'; import { basicSetup, EditorView } from 'codemirror'; import { Transaction, TransactionSpec } from '@codemirror/state'; +import { keymap } from '@codemirror/view'; import { network } from './network'; import { displayLog, displayPeers } from './utils'; import { YorkieDoc, YorkiePresence } from './type'; @@ -19,12 +20,11 @@ async function main() { }); await client.activate(); + const params = new URLSearchParams(window.location.search); + const docKey = params.get('dockey') || `123z12321zz`; // 02-1. create a document then attach it into the client. const doc = new yorkie.Document( - `codemirror6-${new Date() - .toISOString() - .substring(0, 10) - .replace(/-/g, '')}`, + docKey, { enableDevtools: true, }, @@ -48,34 +48,10 @@ async function main() { } }, 'create content if not exists'); - // 02-2. subscribe document event. - const syncText = () => { - const text = doc.getRoot().content; - const selection = doc.getMyPresence().selection; - const transactionSpec: TransactionSpec = { - changes: { from: 0, to: view.state.doc.length, insert: text.toString() }, - annotations: [Transaction.remote.of(true)], - }; - - if (selection) { - // Restore the cursor position when the text is replaced. - const cursor = text.posRangeToIndexRange(selection); - transactionSpec['selection'] = { - anchor: cursor[0], - head: cursor[1], - }; - } - view.dispatch(transactionSpec); - }; - doc.subscribe((event) => { - if (event.type === 'snapshot') { - // The text is replaced to snapshot and must be re-synced. - syncText(); - } - }); + doc.subscribe('$.content', (event) => { - if (event.type === 'remote-change') { + if (event.type === 'remote-change' || event.source === 'undoredo') { const { operations } = event.value; handleOperations(operations); } @@ -87,7 +63,7 @@ async function main() { const updateListener = EditorView.updateListener.of((viewUpdate) => { if (viewUpdate.docChanged) { for (const tr of viewUpdate.transactions) { - const events = ['select', 'input', 'delete', 'move', 'undo', 'redo']; + const events = ['select', 'input', 'delete', 'move']; if (!events.map((event) => tr.isUserEvent(event)).some(Boolean)) { continue; } @@ -138,11 +114,60 @@ async function main() { const fixedHeightTheme = EditorView.theme({ '.cm-content, .cm-gutter': { minHeight: '210px' }, // ~10 lines (≈21px per line including padding) }); + const cmUndoRedoKeymap = keymap.of([ + { + key: 'Mod-Shift-j', + run: () => { + console.log('undo'); + if (doc.history.canUndo()) { + doc.history.undo(); + return true; + } + return false; + }, + }, + { + key: 'Mod-Shift-l', + run: () => { + console.log('redo'); + if (doc.history.canRedo()) { + doc.history.redo(); + return true; + } + return false; + }, + }, + ]); const view = new EditorView({ doc: '', - extensions: [basicSetup, fixedHeightTheme, updateListener], + extensions: [basicSetup, fixedHeightTheme, updateListener, cmUndoRedoKeymap], parent: editorParentElem, }); + // 02-2. subscribe document event. + const syncText = () => { + const text = doc.getRoot().content; + const selection = doc.getMyPresence().selection; + const transactionSpec: TransactionSpec = { + changes: { from: 0, to: view.state.doc.length, insert: text.toString() }, + annotations: [Transaction.remote.of(true)], + }; + + if (selection) { + // Restore the cursor position when the text is replaced. + const cursor = text.posRangeToIndexRange(selection); + transactionSpec['selection'] = { + anchor: cursor[0], + head: cursor[1], + }; + } + view.dispatch(transactionSpec); +}; +doc.subscribe((event) => { + if (event.type === 'snapshot') { + // The text is replaced to snapshot and must be re-synced. + syncText(); + } +}); // 03-3. define event handler that apply remote changes to local function handleOperations(operations: Array) { diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index 1d7badcc8..ad06fec46 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -5,6 +5,7 @@ import { withTwoClientsAndDocuments } from '@yorkie-js/sdk/test/integration/inte type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace']; // TODO(JOOHOJANG): We need to add 'style' operation and multi-client test + /** * Operation Set 1 */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d032dbd4..3ef746ab1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,9 @@ importers: '@codemirror/state': specifier: ^6.5.2 version: 6.5.2 + '@codemirror/view': + specifier: ^6.4.1 + version: 6.38.8 '@yorkie-js/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -6966,7 +6969,7 @@ packages: hasBin: true sharp@0.33.5: - resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==, tarball: https://artifactory.navercorp.com/artifactory/api/npm/npm-naver/sharp/-/sharp-0.33.5.tgz} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} sharp@0.34.5: From cf8927a256aa8d3deb4b220d93151672db9db90e Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Wed, 3 Dec 2025 16:25:57 +0900 Subject: [PATCH 09/20] Add TCs --- .../sdk/test/integration/history_text_test.ts | 98 +++++++++++++++++-- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index ad06fec46..8cd8a76f6 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -6,9 +6,6 @@ type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace']; // TODO(JOOHOJANG): We need to add 'style' operation and multi-client test -/** - * Operation Set 1 - */ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { doc.update((root) => { const t = root.t; @@ -52,9 +49,6 @@ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { }, op); } -/** - * Operation Set 2 - */ function applyTextOp2(doc: Document<{ t: Text }>, op: TextOp) { doc.update((root) => { const t = root.t; @@ -221,6 +215,98 @@ describe('Text Undo - multi client', () => { } } + it('should converge after overlapping edits are undone', async function ({ + task, + }) { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + const initialText = 'ABCDEFGHIJ'; + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, initialText); + }, 'init'); + await c1.sync(); + await c2.sync(); + + d1.update((root) => { + root.t.edit(2, 6, 'XXXX'); + }, 'c1 overlap edit'); + // "ABCXXXXFGHIJ" + d2.update((root) => { + root.t.edit(4, 9, 'YYYYY'); + }, 'c2 overlap edit'); + // "ABCXXXXYYYYY" + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal( + d1.toSortedJSON(), + d2.toSortedJSON(), + 'Mismatch after overlap edits', + ); + // "ABCXXXXYYYYY" + d2.history.undo(); + d1.history.undo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + assert.equal( + d1.toSortedJSON(), + d2.toSortedJSON(), + 'Mismatch after overlap undo', + ); + }, task.name); + }); + + it('should converge after containing edits are undone', async function ({ + task, + }) { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + const initialText = 'ZYXWVUTSRQ'; + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, initialText); + }, 'init'); + + await c1.sync(); + await c2.sync(); + + d1.update((root) => { + root.t.edit(2, 9, 'CONTAIN'); + }, 'c1 outer edit'); + + d2.update((root) => { + root.t.edit(4, 6, 'in'); + }, 'c2 inner edit'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + assert.equal( + d1.toSortedJSON(), + d2.toSortedJSON(), + 'Mismatch after contain edits', + ); + + d2.history.undo(); + d1.history.undo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + assert.equal( + d1.toSortedJSON(), + d2.toSortedJSON(), + 'Mismatch after contain undo', + ); + }, task.name); + }); + // TODO(JOOHOJANG): We need to test this after implementing style operation it.skip('should keep convergence when both clients style/undo/redo', async function ({ task, From 23379abd20873f02b096a3c271b34142d079ba5f Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Wed, 3 Dec 2025 18:31:44 +0900 Subject: [PATCH 10/20] Return operations after execute change to reconcile text reverse operations --- packages/sdk/src/document/change/change.ts | 6 ++++-- packages/sdk/src/document/document.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/document/change/change.ts b/packages/sdk/src/document/change/change.ts index 548812215..2e6c7e33c 100644 --- a/packages/sdk/src/document/change/change.ts +++ b/packages/sdk/src/document/change/change.ts @@ -155,10 +155,12 @@ export class Change

{ presences: Map, source: OpSource, ): { + operations: Array; opInfos: Array; reverseOps: Array>; } { const changeOpInfos: Array = []; + const changeOperations: Array = []; const reverseOps: Array> = []; for (const operation of this.operations) { @@ -172,7 +174,7 @@ export class Change

{ if (!executionResult) continue; const { opInfos, reverseOp } = executionResult; changeOpInfos.push(...opInfos); - + changeOperations.push(operation); // TODO(hackerwins): This condition should be removed after implementing // all reverse operations. if (reverseOp) { @@ -191,7 +193,7 @@ export class Change

{ } } - return { opInfos: changeOpInfos, reverseOps }; + return { operations: changeOperations, opInfos: changeOpInfos, reverseOps }; } /** diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index d85c2888d..355d82656 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -1444,10 +1444,15 @@ export class Document< } } - const { opInfos } = change.execute(this.root, this.presences, source); - for (const op of opInfos) { + const { opInfos, operations } = change.execute( + this.root, + this.presences, + source, + ); + for (const op of operations) { if (op instanceof EditOperation) { const [from, to] = op.normalizePos(this.root); + this.internalHistory.reconcileTextEdit( op.getParentCreatedAt(), from, From fce5d9567c72e5ddc5505711dffe122fb495c59a Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Wed, 3 Dec 2025 19:56:11 +0900 Subject: [PATCH 11/20] Clear history when apply snapshot --- packages/sdk/src/document/document.ts | 6 ++++++ packages/sdk/src/document/history.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index 355d82656..0345982b9 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -1336,6 +1336,7 @@ export class Document< // Afterward, we should publish a snapshot event with the latest // version of the document to ensure the user receives the most up-to-date snapshot. this.applyChanges(this.localChanges, OpSource.Local); + this.clearHistory(); this.publish([ { type: DocEventType.Snapshot, @@ -1351,6 +1352,11 @@ export class Document< ]); } + private clearHistory() { + this.internalHistory.clearRedo(); + this.internalHistory.clearUndo(); + } + /** * `applyChanges` applies the given changes into this document. */ diff --git a/packages/sdk/src/document/history.ts b/packages/sdk/src/document/history.ts index c429d2b91..087fd4110 100644 --- a/packages/sdk/src/document/history.ts +++ b/packages/sdk/src/document/history.ts @@ -99,6 +99,13 @@ export class History

{ this.redoStack = []; } + /** + * `clearUndo` flushes remaining undo operations. + */ + public clearUndo(): void { + this.undoStack = []; + } + /** * `getUndoStackForTest` returns the undo stack for test. */ From 8aabf3f20e9da950cbd6c9699462321a543cb99d Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Thu, 4 Dec 2025 01:16:15 +0900 Subject: [PATCH 12/20] Fix package installation error --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ef746ab1..d07a9ffe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6969,7 +6969,7 @@ packages: hasBin: true sharp@0.33.5: - resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==, tarball: https://artifactory.navercorp.com/artifactory/api/npm/npm-naver/sharp/-/sharp-0.33.5.tgz} + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} sharp@0.34.5: From 7efa55f9ee12b044e8b2064b740bd5cca19a0f35 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Thu, 4 Dec 2025 01:46:31 +0900 Subject: [PATCH 13/20] Address coderabbit review comments --- examples/vanilla-codemirror6/src/main.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/vanilla-codemirror6/src/main.ts b/examples/vanilla-codemirror6/src/main.ts index 15ab9991a..e61d1f89b 100644 --- a/examples/vanilla-codemirror6/src/main.ts +++ b/examples/vanilla-codemirror6/src/main.ts @@ -21,7 +21,7 @@ async function main() { await client.activate(); const params = new URLSearchParams(window.location.search); - const docKey = params.get('dockey') || `123z12321zz`; + const docKey = params.get('dockey') || `vanilla-codemirror6-${Date.now()}`; // 02-1. create a document then attach it into the client. const doc = new yorkie.Document( docKey, @@ -116,25 +116,27 @@ async function main() { }); const cmUndoRedoKeymap = keymap.of([ { - key: 'Mod-Shift-j', + key: 'Mod-z', run: () => { + // To check undo works properly console.log('undo'); if (doc.history.canUndo()) { doc.history.undo(); - return true; + } - return false; + return true; }, }, { - key: 'Mod-Shift-l', + key: 'Mod-Shift-z', run: () => { + // To check redo works properly console.log('redo'); if (doc.history.canRedo()) { doc.history.redo(); - return true; + } - return false; + return true; }, }, ]); From 3bd7e207fbd85c0515f0986f4c197afb4ebe57b5 Mon Sep 17 00:00:00 2001 From: Youngteac Hong Date: Thu, 4 Dec 2025 16:52:45 +0900 Subject: [PATCH 14/20] Fix key mapping for undo/redo --- examples/vanilla-codemirror6/src/main.ts | 67 +++++++++++++----------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/examples/vanilla-codemirror6/src/main.ts b/examples/vanilla-codemirror6/src/main.ts index e61d1f89b..389e69c24 100644 --- a/examples/vanilla-codemirror6/src/main.ts +++ b/examples/vanilla-codemirror6/src/main.ts @@ -21,10 +21,12 @@ async function main() { await client.activate(); const params = new URLSearchParams(window.location.search); - const docKey = params.get('dockey') || `vanilla-codemirror6-${Date.now()}`; // 02-1. create a document then attach it into the client. const doc = new yorkie.Document( - docKey, + `codemirror6-${new Date() + .toISOString() + .substring(0, 10) + .replace(/-/g, '')}`, { enableDevtools: true, }, @@ -48,8 +50,6 @@ async function main() { } }, 'create content if not exists'); - - doc.subscribe('$.content', (event) => { if (event.type === 'remote-change' || event.source === 'undoredo') { const { operations } = event.value; @@ -117,24 +117,24 @@ async function main() { const cmUndoRedoKeymap = keymap.of([ { key: 'Mod-z', + preventDefault: true, run: () => { // To check undo works properly console.log('undo'); if (doc.history.canUndo()) { doc.history.undo(); - } return true; }, }, { key: 'Mod-Shift-z', + preventDefault: true, run: () => { // To check redo works properly console.log('redo'); if (doc.history.canRedo()) { doc.history.redo(); - } return true; }, @@ -142,34 +142,39 @@ async function main() { ]); const view = new EditorView({ doc: '', - extensions: [basicSetup, fixedHeightTheme, updateListener, cmUndoRedoKeymap], + extensions: [ + cmUndoRedoKeymap, + basicSetup, + fixedHeightTheme, + updateListener, + ], parent: editorParentElem, }); - // 02-2. subscribe document event. - const syncText = () => { - const text = doc.getRoot().content; - const selection = doc.getMyPresence().selection; - const transactionSpec: TransactionSpec = { - changes: { from: 0, to: view.state.doc.length, insert: text.toString() }, - annotations: [Transaction.remote.of(true)], - }; - - if (selection) { - // Restore the cursor position when the text is replaced. - const cursor = text.posRangeToIndexRange(selection); - transactionSpec['selection'] = { - anchor: cursor[0], - head: cursor[1], + // 02-2. subscribe document event. + const syncText = () => { + const text = doc.getRoot().content; + const selection = doc.getMyPresence().selection; + const transactionSpec: TransactionSpec = { + changes: { from: 0, to: view.state.doc.length, insert: text.toString() }, + annotations: [Transaction.remote.of(true)], }; - } - view.dispatch(transactionSpec); -}; -doc.subscribe((event) => { - if (event.type === 'snapshot') { - // The text is replaced to snapshot and must be re-synced. - syncText(); - } -}); + + if (selection) { + // Restore the cursor position when the text is replaced. + const cursor = text.posRangeToIndexRange(selection); + transactionSpec['selection'] = { + anchor: cursor[0], + head: cursor[1], + }; + } + view.dispatch(transactionSpec); + }; + doc.subscribe((event) => { + if (event.type === 'snapshot') { + // The text is replaced to snapshot and must be re-synced. + syncText(); + } + }); // 03-3. define event handler that apply remote changes to local function handleOperations(operations: Array) { From dda181a260afaa506c44e228f32a09e605b520d0 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Mon, 8 Dec 2025 11:31:44 +0900 Subject: [PATCH 15/20] Fix normalize pos logic to use offset rather than index --- .../sdk/src/document/crdt/rga_tree_split.ts | 20 +++++++++++++++++-- pnpm-lock.yaml | 10 ++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/document/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index fa6a814cd..75f9bc368 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -800,9 +800,25 @@ export class RGATreeSplit implements GCParent { * absolute offset measured from the head `(0:0)` of the physical chain. */ public normalizePos(pos: RGATreeSplitPos): RGATreeSplitPos { - const index = this.posToIndex(pos, true); + const node = this.findFloorNode(pos.getID()); + if (!node) { + throw new YorkieError( + Code.ErrInvalidArgument, + `the node of the given id should be found: ${pos.getID().toTestString()}`, + ); + } + + let total = pos.getRelativeOffset(); + let curr = node; + let prev = node.getPrev(); + + while (prev) { + total += prev.getLength(); + curr = prev; + prev = prev.getPrev(); + } - return RGATreeSplitPos.of(this.getHead().getID(), index); + return RGATreeSplitPos.of(curr.getID(), total); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d07a9ffe8..c15f39bdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - examples/nextjs-presence/dist/dev: {} - examples/nextjs-scheduler: dependencies: '@yorkie-js/sdk': @@ -179,10 +177,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - examples/nextjs-todolist/dist/dev: {} - - examples/nextjs-todolist/dist/dev/build: {} - examples/profile-stack: dependencies: '@yorkie-js/sdk': @@ -12581,7 +12575,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -12603,7 +12597,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From a3b585d6c73380bc2f5ec5fb65fe745ab68ffaf9 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Mon, 8 Dec 2025 16:59:48 +0900 Subject: [PATCH 16/20] Define test state space and add relevant test cases --- .../sdk/test/integration/history_text_test.ts | 743 ++++++++++++++---- 1 file changed, 580 insertions(+), 163 deletions(-) diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index 8cd8a76f6..42a152df3 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -1,50 +1,57 @@ +/** + * Test State Space: + * ┌─────────────────┬────────────────────────────────────────────────────────┐ + * │ Variable │ Domain │ + * ├─────────────────┼────────────────────────────────────────────────────────┤ + * │ OpType │ {insert, delete, replace, style(TODO: Implement)} │ + * │ Position │ {start, middle, end} │ + * │ ClientCount │ {1, 2} │ + * │ UndoDepth │ {0, 1, 2, 3+} │ + * │ ReconcileCase │ {none, left, right, contained_by, contains, │ + * │ │ overlap_start, overlap_end, adjacent} │ + * └─────────────────┴────────────────────────────────────────────────────────┘ + * + * ReconcileCase Diagram (undo range [a,b), remote range [from,to)): + * Case 1 (left): [--remote--] [--undo--] → shift left + * Case 2 (right): [--undo--] [--remote--] → no change + * Case 3 (contained_by): [-------remote-------] → collapse + * [--undo--] + * Case 4 (contains): [--remote--] → adjust + * [---------undo---------] + * Case 5 (overlap_start):[---remote---] → partial + * [---undo---] + * Case 6 (overlap_end): [---remote---] → partial + * [---undo---] + */ + import { describe, it, assert } from 'vitest'; import { Document, Text } from '@yorkie-js/sdk/src/yorkie'; import { withTwoClientsAndDocuments } from '@yorkie-js/sdk/test/integration/integration_helper'; type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace']; -// TODO(JOOHOJANG): We need to add 'style' operation and multi-client test function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { doc.update((root) => { const t = root.t; + const len = t.length ?? t.toString().length; switch (op) { - case 'insert': { - const len = t.length ?? t.toString().length; + case 'insert': t.edit(len, len, 'X'); break; - } - case 'delete': { - const len = t.length ?? t.toString().length; - if (len >= 3) { - t.edit(1, 2, ''); // del [1,2) - } else if (len > 0) { - t.edit(0, 1, ''); - } + case 'delete': + if (len >= 3) t.edit(1, 2, ''); + else if (len > 0) t.edit(0, 1, ''); break; - } - case 'replace': { - // [1,3) → '12' - const len = t.length ?? t.toString().length; - if (len >= 3) { - t.edit(1, 3, '12'); - } else { - const to = Math.min(1, len); - t.edit(0, to, 'R'); - } + case 'replace': + if (len >= 3) t.edit(1, 3, '12'); + else t.edit(0, Math.min(1, len), 'R'); break; - } - case 'style': { - const len = t.length ?? t.toString().length; - if (len === 0) { - t.edit(0, 0, 'A'); - } - const end = t.length ?? t.toString().length; - t.setStyle(0, end, { bold: true }); + case 'style': + if (len === 0) t.edit(0, 0, 'A'); + t.setStyle(0, t.length ?? t.toString().length, { bold: true }); break; - } } }, op); } @@ -52,50 +59,82 @@ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { function applyTextOp2(doc: Document<{ t: Text }>, op: TextOp) { doc.update((root) => { const t = root.t; + const len = t.length ?? t.toString().length; switch (op) { - case 'insert': { + case 'insert': t.edit(0, 0, 'Q'); break; - } - case 'delete': { - const len = t.length ?? t.toString().length; + case 'delete': if (len > 0) t.edit(len - 1, len, ''); break; - } - case 'replace': { - const len = t.length ?? t.toString().length; + case 'replace': if (len > 0) t.edit(0, 1, 'Z'); else t.edit(0, 0, 'Z'); break; - } } }, op); } -describe('Text Undo - single op', () => { - for (const op of ['insert', 'delete', 'replace'] as Array) { - it(`should undo ${op}`, () => { +// 1. Single Client - Basic Undo/Redo +describe('Text History - single client basic', () => { + const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); + + for (const op of ops) { + it(`should undo/redo ${op}`, () => { const doc = new Document<{ t: Text }>('test-doc'); - // initialize doc.update((root) => { root.t = new Text(); root.t.edit(0, 0, 'The fox jumped.'); }, 'init'); - // undo + const before = contentOf(doc); applyTextOp1(doc, op); + const after = contentOf(doc); + doc.history.undo(); + assert.equal(contentOf(doc), before, `undo ${op} failed`); - assert.equal( - doc.getRoot().t.toString(), - 'The fox jumped.', - `undo ${op} should restore text content`, - ); + doc.history.redo(); + assert.equal(contentOf(doc), after, `redo ${op} failed`); }); } - // TODO(JOOHOJANG): We need to test this after implementing style operation + it('should handle undo-redo round trip multiple times', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + const initial = contentOf(doc); + doc.update((root) => root.t.edit(2, 2, 'XY'), 'insert'); + const modified = contentOf(doc); + + for (let i = 0; i < 3; i++) { + doc.history.undo(); + assert.equal(contentOf(doc), initial, `round ${i} undo failed`); + doc.history.redo(); + assert.equal(contentOf(doc), modified, `round ${i} redo failed`); + } + }); + + it('should clear redo stack when new edit is made after undo', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + doc.update((root) => root.t.edit(4, 4, 'EF'), 'append'); + + doc.history.undo(); + assert.equal(doc.history.canRedo(), true); + + doc.update((root) => root.t.edit(0, 0, 'Z'), 'new edit'); + assert.equal(doc.history.canRedo(), false); + }); + + // TODO(JOOHOJANG): Enable after implementing style operation it.skip('should undo/redo style op', () => { const doc = new Document<{ t: Text }>('test-doc'); doc.update((root) => { @@ -108,22 +147,17 @@ describe('Text Undo - single op', () => { '{"t":[{"attrs":{"bold":true},"val":"The fox jumped."}]}'; applyTextOp1(doc, 'style'); - assert.equal( - doc.toSortedJSON(), - styledJSON, - 'style op should add formatting', - ); + assert.equal(doc.toSortedJSON(), styledJSON); doc.history.undo(); - assert.equal(doc.toSortedJSON(), initialJSON, 'undo should drop style'); + assert.equal(doc.toSortedJSON(), initialJSON); doc.history.redo(); - assert.equal(doc.toSortedJSON(), styledJSON, 'redo should reapply style'); + assert.equal(doc.toSortedJSON(), styledJSON); }); }); -describe('Text Undo - chained ops', () => { - // read the text content +describe('Text History - single client chained ops', () => { const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); for (const op1 of ops) { @@ -131,36 +165,31 @@ describe('Text Undo - chained ops', () => { for (const op3 of ops) { const caseName = `${op1}-${op2}-${op3}`; - it(`should step back correctly: ${caseName}`, () => { + it(`should undo chain correctly: ${caseName}`, () => { const doc = new Document<{ t: Text }>('test-doc'); - doc.update((root) => { root.t = new Text(); root.t.edit(0, 0, 'ABCD'); }, 'init'); - // 텍스트 스냅샷 저장 - const S: Array = []; - S.push(contentOf(doc)); // S0 - + const snapshots: Array = [contentOf(doc)]; applyTextOp1(doc, op1); - S.push(contentOf(doc)); // S1 - + snapshots.push(contentOf(doc)); applyTextOp1(doc, op2); - S.push(contentOf(doc)); // S2 - + snapshots.push(contentOf(doc)); applyTextOp1(doc, op3); - S.push(contentOf(doc)); // S3 + snapshots.push(contentOf(doc)); - // S3 -> S2 -> S1 -> S0 + // Undo: S3 → S2 → S1 → S0 for (let i = 3; i >= 1; i--) { doc.history.undo(); - const back = contentOf(doc); - assert.equal( - back, - S[i - 1], - `undo back to S${i - 1} failed on ${caseName}`, - ); + assert.equal(contentOf(doc), snapshots[i - 1], `undo to S${i - 1}`); + } + + // Redo: S0 → S1 → S2 → S3 + for (let i = 0; i < 3; i++) { + doc.history.redo(); + assert.equal(contentOf(doc), snapshots[i + 1], `redo to S${i + 1}`); } }); } @@ -168,14 +197,208 @@ describe('Text Undo - chained ops', () => { } }); -describe('Text Undo - multi client', () => { +describe('Text History - single client edge cases', () => { + const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); + + // Position: start + it('should handle edit at start position', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + doc.update((root) => root.t.edit(0, 2, ''), 'delete at start'); + assert.equal(contentOf(doc), 'CD'); + + doc.history.undo(); + assert.equal(contentOf(doc), 'ABCD'); + + doc.history.redo(); + assert.equal(contentOf(doc), 'CD'); + }); + + // Position: end + it('should handle edit at end position', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + doc.update((root) => root.t.edit(2, 4, ''), 'delete at end'); + assert.equal(contentOf(doc), 'AB'); + + doc.history.undo(); + assert.equal(contentOf(doc), 'ABCD'); + + doc.history.redo(); + assert.equal(contentOf(doc), 'AB'); + }); + + // Empty text + it('should handle insert into empty text', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + }, 'init'); + assert.equal(contentOf(doc), ''); + + doc.update((root) => root.t.edit(0, 0, 'Hello'), 'insert'); + assert.equal(contentOf(doc), 'Hello'); + + doc.history.undo(); + assert.equal(contentOf(doc), ''); + + doc.history.redo(); + assert.equal(contentOf(doc), 'Hello'); + }); + + // Full deletion + it('should handle full deletion then undo', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + doc.update((root) => root.t.edit(0, 4, ''), 'delete all'); + assert.equal(contentOf(doc), ''); + + doc.history.undo(); + assert.equal(contentOf(doc), 'ABCD'); + + doc.history.redo(); + assert.equal(contentOf(doc), ''); + }); + + // Full replacement + it('should handle full replacement', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'OLD'); + }, 'init'); + + doc.update((root) => { + const len = root.t.length ?? root.t.toString().length; + root.t.edit(0, len, 'NEW'); + }, 'replace all'); + assert.equal(contentOf(doc), 'NEW'); + + doc.history.undo(); + assert.equal(contentOf(doc), 'OLD'); + + doc.history.redo(); + assert.equal(contentOf(doc), 'NEW'); + }); + + // Single character + it('should handle single character operations', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABC'); + }, 'init'); + + doc.update((root) => root.t.edit(1, 1, 'X'), 'insert X'); + assert.equal(contentOf(doc), 'AXBC'); + + doc.history.undo(); + assert.equal(contentOf(doc), 'ABC'); + + doc.update((root) => root.t.edit(1, 2, ''), 'delete B'); + assert.equal(contentOf(doc), 'AC'); + + doc.history.undo(); + assert.equal(contentOf(doc), 'ABC'); + }); + + // UndoDepth=0: empty stacks + it('should handle empty undo stack', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + assert.equal(doc.history.canUndo(), true); + doc.history.undo(); + assert.equal(doc.history.canUndo(), false); + }); + + it('should handle empty redo stack', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + + assert.equal(doc.history.canRedo(), false); + }); + + // Rapid consecutive edits (UndoDepth=3+) + it('should handle rapid consecutive edits', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + }, 'init'); + + const states: Array = ['']; + for (let i = 0; i < 10; i++) { + doc.update((root) => { + const len = root.t.length ?? root.t.toString().length; + root.t.edit(len, len, String(i)); + }, `insert ${i}`); + states.push(contentOf(doc)); + } + + // Undo all + for (let i = 9; i >= 0; i--) { + doc.history.undo(); + assert.equal(contentOf(doc), states[i]); + } + + // Redo all + for (let i = 1; i <= 10; i++) { + doc.history.redo(); + assert.equal(contentOf(doc), states[i]); + } + }); +}); + +describe('Text History - multi client basic', () => { for (const op1 of ops) { for (const op2 of ops) { - const caseName = `${op1}-${op2}`; + it(`should converge after undo: ${op1}-${op2}`, async ({ task }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + applyTextOp1(d1, op1); + applyTextOp2(d2, op2); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON(), 'after ops'); + + d1.history.undo(); + d2.history.undo(); - it(`should converge after both undo: ${caseName}`, async function ({ - task, - }) { + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON(), 'after undo'); + }, task.name); + }); + + it(`should converge after redo: ${op1}-${op2}`, async ({ task }) => { type TestDoc = { t: Text }; await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { d1.update((root) => { @@ -184,7 +407,6 @@ describe('Text Undo - multi client', () => { }, 'init'); await c1.sync(); await c2.sync(); - assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); applyTextOp1(d1, op1); applyTextOp2(d2, op2); @@ -192,11 +414,6 @@ describe('Text Undo - multi client', () => { await c1.sync(); await c2.sync(); await c1.sync(); - assert.equal( - d1.toSortedJSON(), - d2.toSortedJSON(), - 'Mismatch after both ops', - ); d1.history.undo(); d2.history.undo(); @@ -205,143 +422,345 @@ describe('Text Undo - multi client', () => { await c2.sync(); await c1.sync(); - assert.equal( - d1.toSortedJSON(), - d2.toSortedJSON(), - 'Mismatch after both undos', - ); + d1.history.redo(); + d2.history.redo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON(), 'after redo'); }, task.name); }); } } +}); - it('should converge after overlapping edits are undone', async function ({ +describe('Text History - reconcile cases', () => { + it('Case 1 (left): remote edit LEFT of undo should shift position', async ({ task, - }) { + }) => { type TestDoc = { t: Text }; await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { - const initialText = 'ABCDEFGHIJ'; d1.update((root) => { root.t = new Text(); - root.t.edit(0, 0, initialText); + root.t.edit(0, 0, '0123456789'); }, 'init'); await c1.sync(); await c2.sync(); - d1.update((root) => { - root.t.edit(2, 6, 'XXXX'); - }, 'c1 overlap edit'); - // "ABCXXXXFGHIJ" - d2.update((root) => { - root.t.edit(4, 9, 'YYYYY'); - }, 'c2 overlap edit'); - // "ABCXXXXYYYYY" - await c1.sync(); - await c2.sync(); - await c1.sync(); - assert.equal( - d1.toSortedJSON(), - d2.toSortedJSON(), - 'Mismatch after overlap edits', - ); - // "ABCXXXXYYYYY" - d2.history.undo(); + // d1: delete [6,8), d2: insert at 2 (left of d1) + d1.update((root) => root.t.edit(6, 8, ''), 'd1 delete'); + d2.update((root) => root.t.edit(2, 2, 'XX'), 'd2 insert left'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + it('Case 2 (right): remote edit RIGHT of undo should not affect', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, '0123456789'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + // d1: delete [2,4), d2: insert at 8 (right of d1) + d1.update((root) => root.t.edit(2, 4, ''), 'd1 delete'); + d2.update((root) => root.t.edit(8, 8, 'YY'), 'd2 insert right'); await c1.sync(); await c2.sync(); await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); - assert.equal( - d1.toSortedJSON(), - d2.toSortedJSON(), - 'Mismatch after overlap undo', - ); + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); - it('should converge after containing edits are undone', async function ({ + it('Case 3 (contained_by): undo range contained by remote should collapse', async ({ task, - }) { + }) => { type TestDoc = { t: Text }; await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { - const initialText = 'ZYXWVUTSRQ'; d1.update((root) => { root.t = new Text(); - root.t.edit(0, 0, initialText); + root.t.edit(0, 0, '0123456789'); }, 'init'); + await c1.sync(); + await c2.sync(); + + // d1: delete [4,6), d2: delete [2,8) (contains d1's range) + d1.update((root) => root.t.edit(4, 6, ''), 'd1 delete'); + d2.update((root) => root.t.edit(2, 8, ''), 'd2 delete larger'); await c1.sync(); await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + it('Case 4 (contains): remote range contained by undo should adjust', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { d1.update((root) => { - root.t.edit(2, 9, 'CONTAIN'); - }, 'c1 outer edit'); + root.t = new Text(); + root.t.edit(0, 0, '0123456789'); + }, 'init'); + await c1.sync(); + await c2.sync(); - d2.update((root) => { - root.t.edit(4, 6, 'in'); - }, 'c2 inner edit'); + // d1: delete [2,8), d2: insert at 5 (inside d1's range) + d1.update((root) => root.t.edit(2, 8, ''), 'd1 delete large'); + d2.update((root) => root.t.edit(5, 5, 'ZZ'), 'd2 insert inside'); await c1.sync(); await c2.sync(); await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + it('Case 5 (overlap_start): remote overlaps start of undo range', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, '0123456789'); + }, 'init'); + await c1.sync(); + await c2.sync(); - assert.equal( - d1.toSortedJSON(), - d2.toSortedJSON(), - 'Mismatch after contain edits', - ); + // d1: delete [4,8), d2: delete [2,6) (overlaps start) + d1.update((root) => root.t.edit(4, 8, ''), 'd1 delete'); + d2.update((root) => root.t.edit(2, 6, ''), 'd2 overlap start'); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.undo(); d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + it('Case 6 (overlap_end): remote overlaps end of undo range', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, '0123456789'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + // d1: delete [2,6), d2: delete [4,8) (overlaps end) + d1.update((root) => root.t.edit(2, 6, ''), 'd1 delete'); + d2.update((root) => root.t.edit(4, 8, ''), 'd2 overlap end'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + it('Case 7 (adjacent): adjacent edits at boundary', async ({ task }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, '0123456789'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + // d1: delete [4,6), d2: insert at 6 (adjacent) + d1.update((root) => root.t.edit(4, 6, ''), 'd1 delete'); + d2.update((root) => root.t.edit(6, 6, 'AA'), 'd2 insert adjacent'); await c1.sync(); await c2.sync(); await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); - assert.equal( - d1.toSortedJSON(), - d2.toSortedJSON(), - 'Mismatch after contain undo', - ); + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); +}); - // TODO(JOOHOJANG): We need to test this after implementing style operation - it.skip('should keep convergence when both clients style/undo/redo', async function ({ +describe('Text History - multi client edge cases', () => { + it('should converge with same position concurrent edits', async function ({ task, }) { type TestDoc = { t: Text }; await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { - const initialSetup = () => { - d1.update((root) => { - root.t = new Text(); - root.t.edit(0, 0, 'The fox jumped.'); - }, 'init'); - }; + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + // Both insert at position 2 + d1.update((root) => root.t.edit(2, 2, 'X'), 'd1 insert'); + d2.update((root) => root.t.edit(2, 2, 'Y'), 'd2 insert'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.undo(); + d2.history.undo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + it('should converge with concurrent full deletion and insertion', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'ABCD'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + d1.update((root) => root.t.edit(0, 4, ''), 'd1 delete all'); + d2.update((root) => root.t.edit(0, 0, 'XY'), 'd2 insert'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.undo(); + d2.history.undo(); - initialSetup(); await c1.sync(); await c2.sync(); - const initialJSON = '{"t":[{"val":"The fox jumped."}]}'; - assert.equal(d1.toSortedJSON(), initialJSON); - assert.equal(d2.toSortedJSON(), initialJSON); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + it('should converge when one client undos and other redos', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { d1.update((root) => { - root.t.setStyle(0, 15, { bold: true }); - }, 'style bold by c1'); - d2.update((root) => { - root.t.setStyle(4, 15, { italic: true }); - }, 'style italic by c2'); + root.t = new Text(); + root.t.edit(0, 0, 'ABCDEFGH'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + d1.update((root) => root.t.edit(2, 4, 'XX'), 'd1 edit'); + d2.update((root) => root.t.edit(6, 8, 'YY'), 'd2 edit'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + // d1: undo then redo, d2: just undo + d1.history.undo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + + d1.history.redo(); + d2.history.undo(); await c1.sync(); await c2.sync(); await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + }, task.name); + }); + + // TODO(JOOHOJANG): Enable after implementing style operation + it.skip('should converge with concurrent style operations', async ({ + task, + }) => { + type TestDoc = { t: Text }; + await withTwoClientsAndDocuments(async (c1, d1, c2, d2) => { + d1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + d1.update((root) => root.t.setStyle(0, 15, { bold: true }), 'bold'); + d2.update((root) => root.t.setStyle(4, 15, { italic: true }), 'italic'); - const styledJSON = - '{"t":[{"attrs":{"bold":true},"val":"The "},{"attrs":{"bold":true,"italic":true},"val":"fox jumped."}]}'; - assert.equal(d1.toSortedJSON(), styledJSON, 'Mismatch after style ops'); - assert.equal(d2.toSortedJSON(), styledJSON, 'Mismatch after style ops'); + await c1.sync(); + await c2.sync(); + await c1.sync(); d1.history.undo(); d2.history.undo(); @@ -349,8 +768,7 @@ describe('Text Undo - multi client', () => { await c1.sync(); await c2.sync(); await c1.sync(); - assert.equal(d1.toSortedJSON(), initialJSON, 'Mismatch after style undo'); - assert.equal(d2.toSortedJSON(), initialJSON, 'Mismatch after style undo'); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); d1.history.redo(); d2.history.redo(); @@ -358,8 +776,7 @@ describe('Text Undo - multi client', () => { await c1.sync(); await c2.sync(); await c1.sync(); - assert.equal(d1.toSortedJSON(), styledJSON, 'Mismatch after style redo'); - assert.equal(d2.toSortedJSON(), styledJSON, 'Mismatch after style redo'); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); }); From 000e4876e241efb3a984b8fadbca85ffc26224db Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Mon, 8 Dec 2025 17:53:46 +0900 Subject: [PATCH 17/20] Add redo after undo test --- .../sdk/test/integration/history_text_test.ts | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index 42a152df3..af20be819 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -10,18 +10,6 @@ * │ ReconcileCase │ {none, left, right, contained_by, contains, │ * │ │ overlap_start, overlap_end, adjacent} │ * └─────────────────┴────────────────────────────────────────────────────────┘ - * - * ReconcileCase Diagram (undo range [a,b), remote range [from,to)): - * Case 1 (left): [--remote--] [--undo--] → shift left - * Case 2 (right): [--undo--] [--remote--] → no change - * Case 3 (contained_by): [-------remote-------] → collapse - * [--undo--] - * Case 4 (contains): [--remote--] → adjust - * [---------undo---------] - * Case 5 (overlap_start):[---remote---] → partial - * [---undo---] - * Case 6 (overlap_end): [---remote---] → partial - * [---undo---] */ import { describe, it, assert } from 'vitest'; @@ -435,6 +423,19 @@ describe('Text History - multi client basic', () => { } }); +/** + * ReconcileCase Diagram (undo range [a,b), remote range [from,to)): + * Case 1 (left): [--remote--] [--undo--] → shift left + * Case 2 (right): [--undo--] [--remote--] → no change + * Case 3 (contained_by): [-------remote-------] → collapse + * [--undo--] + * Case 4 (contains): [--remote--] → adjust + * [---------undo---------] + * Case 5 (overlap_start):[---remote---] → partial + * [---undo---] + * Case 6 (overlap_end): [---remote---] → partial + * [---undo---] + */ describe('Text History - reconcile cases', () => { it('Case 1 (left): remote edit LEFT of undo should shift position', async ({ task, @@ -463,6 +464,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); @@ -493,6 +501,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); @@ -523,6 +538,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); @@ -553,6 +575,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); @@ -583,6 +612,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); @@ -613,6 +649,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); @@ -641,6 +684,13 @@ describe('Text History - reconcile cases', () => { await c2.sync(); await c1.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + + d1.history.redo(); + d2.history.redo(); + await c1.sync(); + await c2.sync(); + await c1.sync(); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, task.name); }); }); From bf0b70e9583d1e9628ef7272404d7c46735ea194 Mon Sep 17 00:00:00 2001 From: JOOHOJANG Date: Tue, 9 Dec 2025 11:09:30 +0900 Subject: [PATCH 18/20] Remove unused param --- examples/vanilla-codemirror6/src/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/vanilla-codemirror6/src/main.ts b/examples/vanilla-codemirror6/src/main.ts index 389e69c24..bde2208fd 100644 --- a/examples/vanilla-codemirror6/src/main.ts +++ b/examples/vanilla-codemirror6/src/main.ts @@ -20,7 +20,6 @@ async function main() { }); await client.activate(); - const params = new URLSearchParams(window.location.search); // 02-1. create a document then attach it into the client. const doc = new yorkie.Document( `codemirror6-${new Date() From 53eef5757f3b04ed02fa66004c7a27701efaf793 Mon Sep 17 00:00:00 2001 From: Youngteac Hong Date: Tue, 9 Dec 2025 16:02:01 +0900 Subject: [PATCH 19/20] Improve cursor movement for undo/redo --- examples/vanilla-codemirror6/src/main.ts | 32 ++++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/examples/vanilla-codemirror6/src/main.ts b/examples/vanilla-codemirror6/src/main.ts index bde2208fd..42cfe37ce 100644 --- a/examples/vanilla-codemirror6/src/main.ts +++ b/examples/vanilla-codemirror6/src/main.ts @@ -50,9 +50,12 @@ async function main() { }, 'create content if not exists'); doc.subscribe('$.content', (event) => { - if (event.type === 'remote-change' || event.source === 'undoredo') { + if (event.type === 'remote-change') { const { operations } = event.value; - handleOperations(operations); + handleOperations(operations, false); + } else if (event.source === 'undoredo') { + const { operations } = event.value; + handleOperations(operations, true); } }); @@ -118,8 +121,6 @@ async function main() { key: 'Mod-z', preventDefault: true, run: () => { - // To check undo works properly - console.log('undo'); if (doc.history.canUndo()) { doc.history.undo(); } @@ -130,8 +131,6 @@ async function main() { key: 'Mod-Shift-z', preventDefault: true, run: () => { - // To check redo works properly - console.log('redo'); if (doc.history.canRedo()) { doc.history.redo(); } @@ -176,14 +175,14 @@ async function main() { }); // 03-3. define event handler that apply remote changes to local - function handleOperations(operations: Array) { + function handleOperations(operations: Array, moveCursor: boolean) { for (const op of operations) { if (op.type === 'edit') { - handleEditOp(op); + handleEditOp(op, moveCursor); } } } - function handleEditOp(op: EditOpInfo) { + function handleEditOp(op: EditOpInfo, moveCursor: boolean) { const changes = [ { from: Math.max(0, op.from), @@ -192,10 +191,21 @@ async function main() { }, ]; - view.dispatch({ + const transactionSpec: TransactionSpec = { changes, annotations: [Transaction.remote.of(true)], - }); + }; + + // Move cursor to the changed position for undo/redo + if (moveCursor) { + const newPosition = op.from + (op.value?.content?.length || 0); + transactionSpec.selection = { + anchor: newPosition, + head: newPosition, + }; + } + + view.dispatch(transactionSpec); } syncText(); From 4d2d0e6fae7bbec7c4ecbf2a72ebe65adf452a34 Mon Sep 17 00:00:00 2001 From: Youngteac Hong Date: Tue, 9 Dec 2025 18:08:53 +0900 Subject: [PATCH 20/20] Revise codes --- packages/sdk/src/document/history.ts | 2 + .../src/document/operation/edit_operation.ts | 111 +++++++++++------- .../sdk/test/integration/history_text_test.ts | 9 +- 3 files changed, 76 insertions(+), 46 deletions(-) diff --git a/packages/sdk/src/document/history.ts b/packages/sdk/src/document/history.ts index 087fd4110..1d2c007c0 100644 --- a/packages/sdk/src/document/history.ts +++ b/packages/sdk/src/document/history.ts @@ -135,6 +135,7 @@ export class History

{ currCreatedAt: TimeTicket, ): void { const replace = (stack: Array>>) => { + // TODO(hackerwins): Optimize by indexing operations. for (const ops of stack) { for (const op of ops) { if ( @@ -170,6 +171,7 @@ export class History

{ contentLength: number, ): void { const replace = (stack: Array>>) => { + // TODO(hackerwins): Optimize by indexing operations. for (const ops of stack) { for (const op of ops) { if ( diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index 10549976b..46796f1c0 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -142,33 +142,29 @@ export class EditOperation extends Operation { private toReverseOperation( removedValues: Array, - normalizedFromPos: RGATreeSplitPos, + fromPos: RGATreeSplitPos, ): Operation { - // 1) Content - const restoredContent = - removedValues && removedValues.length !== 0 - ? removedValues.map((v) => v.getContent()).join('') - : ''; - - // 2) Attribute - let restoredAttrs: Array<[string, string]> | undefined; + const content = removedValues?.length + ? removedValues.map((v) => v.getContent()).join('') + : ''; + let attrs: Array<[string, string]> | undefined; if (removedValues.length === 1) { const attrsObj = removedValues[0].getAttributes(); if (attrsObj) { - restoredAttrs = Array.from(Object.entries(attrsObj as any)); + attrs = Array.from(Object.entries(attrsObj as any)); } } return EditOperation.create( this.getParentCreatedAt(), - normalizedFromPos, + fromPos, RGATreeSplitPos.of( - normalizedFromPos.getID(), - normalizedFromPos.getRelativeOffset() + (this.content?.length ?? 0), + fromPos.getID(), + fromPos.getRelativeOffset() + (this.content?.length ?? 0), ), - restoredContent, - restoredAttrs ? new Map(restoredAttrs) : new Map(), + content, + attrs ? new Map(attrs) : new Map(), undefined, true, ); @@ -202,60 +198,93 @@ export class EditOperation extends Operation { } /** - * `reconcileOperation` reconciles the edit operation with the new position. + * `reconcileOperation` reconciles the position when remote edits occur. + * + * @param remoteFrom - Start position of the remote edit + * @param remoteTo - End position of the remote edit + * @param contentLen - Length of content inserted by the remote edit + * + * @example + * // Text: "0123456789" + * // Undo range: [4, 6) (trying to restore "45") + * // Remote edit: delete [2, 4) and insert "XY" + * // Result: Undo range adjusted to [2, 4) to restore at correct position */ public reconcileOperation( - rangeFrom: number, - rangeTo: number, - contentLength: number, + remoteFrom: number, + remoteTo: number, + contentLen: number, ): void { if (!this.isUndoOp) { return; } - if (!Number.isInteger(rangeFrom) || !Number.isInteger(rangeTo)) { - return; - } - if (rangeFrom > rangeTo) { + if (remoteFrom > remoteTo) { return; } - const rangeLen = rangeTo - rangeFrom; - const a = this.fromPos.getRelativeOffset(); - const b = this.toPos.getRelativeOffset(); + const remoteRangeLen = remoteTo - remoteFrom; + const localFrom = this.fromPos.getRelativeOffset(); + const localTo = this.toPos.getRelativeOffset(); + // Helper function to apply new position offsets const apply = (na: number, nb: number) => { this.fromPos = RGATreeSplitPos.of(this.fromPos.getID(), Math.max(0, na)); this.toPos = RGATreeSplitPos.of(this.toPos.getID(), Math.max(0, nb)); }; - // Does not overlap - if (rangeTo <= a) { - apply(a - rangeLen + contentLength, b - rangeLen + contentLength); + // Case 1: Remote edit is to the left of undo range + // [--remote--] [--undo--] + if (remoteTo <= localFrom) { + apply( + localFrom - remoteRangeLen + contentLen, + localTo - remoteRangeLen + contentLen, + ); return; } - if (b <= rangeFrom) { + + // Case 2: Remote edit is to the right of undo range + // [--undo--] [--remote--] + if (localTo <= remoteFrom) { return; } - // Fully overlap: contains - if (rangeFrom <= a && b <= rangeTo && rangeFrom !== rangeTo) { - apply(rangeFrom, rangeFrom); + // Case 3: Undo range is contained within remote range + // [-------remote-------] + // [--undo--] + if ( + remoteFrom <= localFrom && + localTo <= remoteTo && + remoteFrom !== remoteTo + ) { + apply(remoteFrom, remoteFrom); return; } - if (a <= rangeFrom && rangeTo <= b && a !== b) { - apply(a, b - rangeLen + contentLength); + + // Case 4: Remote range is contained within undo range + // [--remote--] + // [---------undo---------] + if ( + localFrom <= remoteFrom && + remoteTo <= localTo && + localFrom !== localTo + ) { + apply(localFrom, localTo - remoteRangeLen + contentLen); return; } - // overlap at the start - if (rangeFrom < a && a < rangeTo && rangeTo < b) { - apply(rangeFrom, rangeFrom + (b - rangeTo)); + // Case 5: Remote range overlaps the start of undo range + // [---remote---] + // [---undo---] + if (remoteFrom < localFrom && localFrom < remoteTo && remoteTo < localTo) { + apply(remoteFrom, remoteFrom + (localTo - remoteTo)); return; } - // overlap at the end - if (a < rangeFrom && rangeFrom < b && b < rangeTo) { - apply(a, rangeFrom); + // Case 6: Remote range overlaps the end of undo range + // [---remote---] + // [---undo---] + if (localFrom < remoteFrom && remoteFrom < localTo && localTo < remoteTo) { + apply(localFrom, remoteFrom); return; } } diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index af20be819..2c8aa5ee7 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -1,3 +1,7 @@ +import { describe, it, assert } from 'vitest'; +import { Document, Text } from '@yorkie-js/sdk/src/yorkie'; +import { withTwoClientsAndDocuments } from '@yorkie-js/sdk/test/integration/integration_helper'; + /** * Test State Space: * ┌─────────────────┬────────────────────────────────────────────────────────┐ @@ -11,11 +15,6 @@ * │ │ overlap_start, overlap_end, adjacent} │ * └─────────────────┴────────────────────────────────────────────────────────┘ */ - -import { describe, it, assert } from 'vitest'; -import { Document, Text } from '@yorkie-js/sdk/src/yorkie'; -import { withTwoClientsAndDocuments } from '@yorkie-js/sdk/test/integration/integration_helper'; - type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace'];