From 897fe6152a9fe8337bc56a193ec2789d87e24767 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Sat, 23 Aug 2025 15:27:42 +0900 Subject: [PATCH 01/14] genesis --- .../sdk/test/integration/history_text_test.ts | 251 ++++++++++++++++++ packages/sdk/vitest.config.ts | 3 +- 2 files changed, 253 insertions(+), 1 deletion(-) 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..fd725862d --- /dev/null +++ b/packages/sdk/test/integration/history_text_test.ts @@ -0,0 +1,251 @@ +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', 'style']; + +/** + * 단일 클라이언트용: root.t에 op1 세트를 적용 + * 문서는 항상 초기 콘텐츠를 충분히 갖도록 설정해서 인덱스 안전하게 씁니다. + */ +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, ''); // [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 } as any); + break; + } + } + }, op); +} + +/** + * 두 번째 클라이언트용: op2 세트를 적용 + * 인덱스를 다르게 잡아서 충돌/병합 시나리오를 만들어봄. + */ +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': { + // [0,1) → 'Z' 로 치환 (가능하면) + 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}`, () => { + const doc = new Document<{ t: Text }>('test-doc'); + // 초기화 + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + + // op 적용 후 undo + applyTextOp1(doc, op); + doc.history.undo(); + + assert.equal( + doc.getRoot().t.toString(), + 'The fox jumped.', + `undo ${op} should restore text content`, + ); + }); + } +}); + +describe('Text Undo - chained ops', () => { + // 텍스트 내용만 읽는 헬퍼 + const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); + + for (const op1 of ['replace'] as Array) { + for (const op2 of ['delete'] as Array) { + for (const op3 of ['delete'] as Array) { + 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}`, + ); + } + }); + } + } + } +}); + +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()); + + // 각자 op 적용 + 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', + ); + + // 둘 다 undo + 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); + }); + } + } +}); + +/** + * 특수 케이스: 복합 치환/스타일 섞인 시나리오에 대한 빠른 회귀 테스트 + */ +describe('Text Undo - mixed scenario smoke test', () => { + it('should undo replace+style+insert in order', () => { + const doc = new Document<{ t: Text }>('test-doc'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'Hello World'); + }, 'init'); + + const S0 = doc.toSortedJSON(); + + // replace + doc.update((root) => root.t.edit(6, 11, 'Yorkie'), 'replace'); + const S1 = doc.toSortedJSON(); + + // style + doc.update( + (root) => root.t.setStyle(0, 11, { bold: true } as any), + 'style', + ); + const S2 = doc.toSortedJSON(); + + // insert + doc.update((root) => root.t.edit(11, 11, '!'), 'insert'); + // const S3 = doc.toSortedJSON(); + + // undo insert + doc.history.undo(); + assert.equal(doc.toSortedJSON(), S2, 'undo insert'); + + // undo style + doc.history.undo(); + assert.equal(doc.toSortedJSON(), S1, 'undo style'); + + // undo replace + doc.history.undo(); + assert.equal(doc.toSortedJSON(), S0, 'undo replace'); + }); +}); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index e2afb57ae..110155f07 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ benchmark: { exclude: ['**/lib/**', '**/node_modules/**'], }, - setupFiles: ['./test/vitest.setup.ts'], + // setupFiles: ['./test/vitest.setup.ts'], + setupFiles: ['../../test/vitest.setup.ts'], }, plugins: [ tsconfigPaths({ From 817d69cce3e1061fadc3dce7cfa26e87ebc7bfa3 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Sat, 23 Aug 2025 15:30:45 +0900 Subject: [PATCH 02/14] edit operation --- .../src/document/operation/edit_operation.ts | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index d0f57e6ee..077e86358 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -17,8 +17,11 @@ 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 { + RGATreeSplitPos, + RGATreeSplitPosRange, +} from '@yorkie-js/sdk/src/document/crdt/rga_tree_split'; +import { CRDTText, CRDTTextValue } from '@yorkie-js/sdk/src/document/crdt/text'; import { Operation, OperationInfo, @@ -44,7 +47,7 @@ export class EditOperation extends Operation { toPos: RGATreeSplitPos, content: string, attributes: Map, - executedAt: TimeTicket, + executedAt?: TimeTicket, ) { super(parentCreatedAt, executedAt); this.fromPos = fromPos; @@ -62,7 +65,7 @@ export class EditOperation extends Operation { toPos: RGATreeSplitPos, content: string, attributes: Map, - executedAt: TimeTicket, + executedAt?: TimeTicket, ): EditOperation { return new EditOperation( parentCreatedAt, @@ -97,7 +100,7 @@ export class EditOperation extends Operation { } const text = parentObject as CRDTText; - const [changes, pairs, diff] = text.edit( + const [changes, pairs, diff, caretPos, removed] = text.edit( [this.fromPos, this.toPos], this.content, this.getExecutedAt(), @@ -105,8 +108,9 @@ export class EditOperation extends Operation { versionVector, ); - root.acc(diff); + const reverseOp = this.toReverseOperation(caretPos, removed); + root.acc(diff); for (const pair of pairs) { root.registerGCPair(pair); } @@ -121,9 +125,40 @@ export class EditOperation extends Operation { path: root.createPath(this.getParentCreatedAt()), } as OperationInfo; }), + reverseOp, }; } + private toReverseOperation( + caretPos: RGATreeSplitPosRange, + removedValues: Array, + ): Operation | undefined { + // 1) Content + const restoredContent = + removedValues && removedValues.length !== 0 + ? removedValues.map((v) => v.getContent()).join('') + : ''; + + // 2) Arttibute + let restoredAttrs: Array<[string, string]> | undefined; + if (removedValues.length === 1) { + const attrsObj = removedValues[0].getAttributes(); + if (attrsObj) { + // Object.fromEntries에 맞출 수 있도록 엔트리 배열로 변환 + restoredAttrs = Array.from(Object.entries(attrsObj as any)); + } + } + + // 3) Create Reverse Operation + return EditOperation.create( + this.getParentCreatedAt(), + this.fromPos, + caretPos[0], + restoredContent, + restoredAttrs ? new Map(restoredAttrs) : new Map(), + ); + } + /** * `getEffectedCreatedAt` returns the creation time of the effected element. */ From 0973561573ec94055593d27ea07135ef44086a06 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Sat, 23 Aug 2025 16:06:36 +0900 Subject: [PATCH 03/14] CMS --- packages/sdk/test/integration/history_text_test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index fd725862d..59c5b860c 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -114,9 +114,9 @@ describe('Text Undo - chained ops', () => { // 텍스트 내용만 읽는 헬퍼 const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); - for (const op1 of ['replace'] as Array) { - for (const op2 of ['delete'] as Array) { - for (const op3 of ['delete'] as Array) { + for (const op1 of ['insert', 'delete', 'replace'] as Array) { + for (const op2 of ['insert', 'delete', 'replace'] as Array) { + for (const op3 of ['insert', 'delete', 'replace'] as Array) { const caseName = `${op1}-${op2}-${op3}`; it(`should step back correctly: ${caseName}`, () => { From e49707ac1151df56320cd5c83809eb145efb92a6 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Sat, 23 Aug 2025 16:23:20 +0900 Subject: [PATCH 04/14] CMS --- .../sdk/src/document/crdt/rga_tree_split.ts | 56 ++++++++++++++++++- packages/sdk/src/document/crdt/text.ts | 26 ++++++--- .../src/document/operation/edit_operation.ts | 42 ++++++++++---- .../sdk/test/integration/history_text_test.ts | 50 ++--------------- packages/sdk/vitest.config.ts | 4 +- 5 files changed, 110 insertions(+), 68 deletions(-) diff --git a/packages/sdk/src/document/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index 0dcec42cf..84a682081 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -595,7 +595,13 @@ export class RGATreeSplit implements GCParent { 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 @@ -650,11 +656,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]; } /** @@ -723,6 +731,50 @@ export class RGATreeSplit implements GCParent { return this.treeByID; } + /** + * `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.getLength(); + 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); + } + /** * `toString` returns the string encoding of this RGATreeSplit. */ diff --git a/packages/sdk/src/document/crdt/text.ts b/packages/sdk/src/document/crdt/text.ts index 14bbd72f3..ae0ea54b0 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'; @@ -241,7 +242,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)) { @@ -249,12 +256,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, @@ -270,7 +273,7 @@ export class CRDTText extends CRDTElement { type: TextChangeType.Content, })); - return [changes, pairs, diff, [caretPos, caretPos]]; + return [changes, pairs, diff, [caretPos, caretPos], removedValues]; } /** @@ -416,6 +419,13 @@ export class CRDTText extends CRDTElement { }; } + /** + * `refinePos` refines the given RGATreeSplitPos. + */ + public refinePos(pos: RGATreeSplitPos): RGATreeSplitPos { + return this.rgaTreeSplit.refinePos(pos); + } + /** * `toJSON` returns the JSON encoding of this text. */ diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index 077e86358..4cf9477a2 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -17,10 +17,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, - RGATreeSplitPosRange, -} from '@yorkie-js/sdk/src/document/crdt/rga_tree_split'; +import { RGATreeSplitPos } from '@yorkie-js/sdk/src/document/crdt/rga_tree_split'; import { CRDTText, CRDTTextValue } from '@yorkie-js/sdk/src/document/crdt/text'; import { Operation, @@ -40,6 +37,7 @@ export class EditOperation extends Operation { private toPos: RGATreeSplitPos; private content: string; private attributes: Map; + private isUndoOp: boolean | undefined; constructor( parentCreatedAt: TimeTicket, @@ -48,12 +46,14 @@ export class EditOperation extends Operation { content: string, attributes: Map, executedAt?: TimeTicket, + isUndoOp?: boolean, ) { super(parentCreatedAt, executedAt); this.fromPos = fromPos; this.toPos = toPos; this.content = content; this.attributes = attributes; + this.isUndoOp = isUndoOp; } /** @@ -66,6 +66,7 @@ export class EditOperation extends Operation { content: string, attributes: Map, executedAt?: TimeTicket, + isUndoOp?: boolean, ): EditOperation { return new EditOperation( parentCreatedAt, @@ -74,6 +75,7 @@ export class EditOperation extends Operation { content, attributes, executedAt, + isUndoOp, ); } @@ -98,9 +100,12 @@ export class EditOperation extends Operation { `fail to execute, only Text can execute edit`, ); } - const text = parentObject as CRDTText; - const [changes, pairs, diff, caretPos, removed] = text.edit( + + if (this.isUndoOp && !this.fromPos.equals(this.toPos)) { + this.toPos = text.refinePos(this.toPos); + } + const [changes, pairs, diff, , removed] = text.edit( [this.fromPos, this.toPos], this.content, this.getExecutedAt(), @@ -108,7 +113,7 @@ export class EditOperation extends Operation { versionVector, ); - const reverseOp = this.toReverseOperation(caretPos, removed); + const reverseOp = this.toReverseOperation(removed); root.acc(diff); for (const pair of pairs) { @@ -128,9 +133,22 @@ export class EditOperation extends Operation { reverseOp, }; } + /** + * `toReverseOperation` creates the reverse EditOperation for undo. + * + * - Restores the content and attributes from `removedValues`. + * - If multiple values were removed, their contents are concatenated. + * - If exactly one value was removed, its attributes are also restored. + * - Computes the target range for the reverse operation: + * - `fromPos`: the original start position of this edit. + * - `toPos` : fromPos advanced by the length of inserted content + * → This ensures that the reverse operation deletes the text + * that was inserted by the original operation. (by refinePos) + * - Returns a new `EditOperation` that, when executed, restores + * the removed content and attributes in place of the inserted one. + */ private toReverseOperation( - caretPos: RGATreeSplitPosRange, removedValues: Array, ): Operation | undefined { // 1) Content @@ -144,7 +162,6 @@ export class EditOperation extends Operation { if (removedValues.length === 1) { const attrsObj = removedValues[0].getAttributes(); if (attrsObj) { - // Object.fromEntries에 맞출 수 있도록 엔트리 배열로 변환 restoredAttrs = Array.from(Object.entries(attrsObj as any)); } } @@ -153,9 +170,14 @@ export class EditOperation extends Operation { return EditOperation.create( this.getParentCreatedAt(), this.fromPos, - caretPos[0], + RGATreeSplitPos.of( + this.fromPos.getID(), + this.fromPos.getRelativeOffset() + (this.content?.length ?? 0), + ), restoredContent, restoredAttrs ? new Map(restoredAttrs) : new Map(), + undefined, + true, ); } diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index 59c5b860c..8d6d65c4b 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -3,7 +3,7 @@ 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', 'style']; +const ops: Array = ['insert', 'delete', 'replace']; /** * 단일 클라이언트용: root.t에 op1 세트를 적용 @@ -114,9 +114,9 @@ describe('Text Undo - chained ops', () => { // 텍스트 내용만 읽는 헬퍼 const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); - for (const op1 of ['insert', 'delete', 'replace'] as Array) { - for (const op2 of ['insert', 'delete', 'replace'] as Array) { - for (const op3 of ['insert', 'delete', 'replace'] as Array) { + 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}`, () => { @@ -207,45 +207,3 @@ describe('Text Undo - multi client', () => { } } }); - -/** - * 특수 케이스: 복합 치환/스타일 섞인 시나리오에 대한 빠른 회귀 테스트 - */ -describe('Text Undo - mixed scenario smoke test', () => { - it('should undo replace+style+insert in order', () => { - const doc = new Document<{ t: Text }>('test-doc'); - doc.update((root) => { - root.t = new Text(); - root.t.edit(0, 0, 'Hello World'); - }, 'init'); - - const S0 = doc.toSortedJSON(); - - // replace - doc.update((root) => root.t.edit(6, 11, 'Yorkie'), 'replace'); - const S1 = doc.toSortedJSON(); - - // style - doc.update( - (root) => root.t.setStyle(0, 11, { bold: true } as any), - 'style', - ); - const S2 = doc.toSortedJSON(); - - // insert - doc.update((root) => root.t.edit(11, 11, '!'), 'insert'); - // const S3 = doc.toSortedJSON(); - - // undo insert - doc.history.undo(); - assert.equal(doc.toSortedJSON(), S2, 'undo insert'); - - // undo style - doc.history.undo(); - assert.equal(doc.toSortedJSON(), S1, 'undo style'); - - // undo replace - doc.history.undo(); - assert.equal(doc.toSortedJSON(), S0, 'undo replace'); - }); -}); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index 110155f07..e0237abbe 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -20,8 +20,8 @@ export default defineConfig({ benchmark: { exclude: ['**/lib/**', '**/node_modules/**'], }, - // setupFiles: ['./test/vitest.setup.ts'], - setupFiles: ['../../test/vitest.setup.ts'], + setupFiles: ['./test/vitest.setup.ts'], + // setupFiles: ['../../test/vitest.setup.ts'], }, plugins: [ tsconfigPaths({ From 4de1429be8899ae1995d61c0e1b7bf47af0fefed Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Sat, 23 Aug 2025 16:28:18 +0900 Subject: [PATCH 05/14] resolve statical analysed bug --- packages/sdk/src/document/crdt/rga_tree_split.ts | 2 +- 1 file changed, 1 insertion(+), 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 84a682081..92ce002fb 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -761,7 +761,7 @@ export class RGATreeSplit implements GCParent { } let offsetInPart = pos.getRelativeOffset(); - let partLen = node.getLength(); + let partLen = node.getContentLength(); while (offsetInPart > partLen) { offsetInPart -= partLen; const next: RGATreeSplitNode | undefined = node!.getNext(); From c5784cea04496d69ba0e023cedbe08a3049ffbe7 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 25 Aug 2025 10:28:37 +0900 Subject: [PATCH 06/14] resolve lint issue and remove Korean comments --- .../sdk/src/document/crdt/rga_tree_split.ts | 2 +- .../sdk/test/integration/history_text_test.ts | 31 ++++++------------- packages/sdk/vitest.config.ts | 1 - 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/sdk/src/document/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index 92ce002fb..334ccb617 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -588,7 +588,7 @@ 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, diff --git a/packages/sdk/test/integration/history_text_test.ts b/packages/sdk/test/integration/history_text_test.ts index 8d6d65c4b..e3157424c 100644 --- a/packages/sdk/test/integration/history_text_test.ts +++ b/packages/sdk/test/integration/history_text_test.ts @@ -6,8 +6,7 @@ type TextOp = 'insert' | 'delete' | 'replace' | 'style'; const ops: Array = ['insert', 'delete', 'replace']; /** - * 단일 클라이언트용: root.t에 op1 세트를 적용 - * 문서는 항상 초기 콘텐츠를 충분히 갖도록 설정해서 인덱스 안전하게 씁니다. + * Operation Set 1 */ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { doc.update((root) => { @@ -15,41 +14,37 @@ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { 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, ''); // [1,2) 삭제 + t.edit(1, 2, ''); // del [1,2) } else if (len > 0) { t.edit(0, 1, ''); } break; } case 'replace': { - // [1,3) → '12' 로 치환 (가능하면) + // [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 } as any); + t.setStyle(0, end, { bold: true }); break; } } @@ -57,8 +52,7 @@ function applyTextOp1(doc: Document<{ t: Text }>, op: TextOp) { } /** - * 두 번째 클라이언트용: op2 세트를 적용 - * 인덱스를 다르게 잡아서 충돌/병합 시나리오를 만들어봄. + * Operation Set 2 */ function applyTextOp2(doc: Document<{ t: Text }>, op: TextOp) { doc.update((root) => { @@ -66,18 +60,15 @@ function applyTextOp2(doc: Document<{ t: Text }>, op: TextOp) { 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': { - // [0,1) → 'Z' 로 치환 (가능하면) const len = t.length ?? t.toString().length; if (len > 0) t.edit(0, 1, 'Z'); else t.edit(0, 0, 'Z'); @@ -91,13 +82,13 @@ 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'); - // op 적용 후 undo + // undo applyTextOp1(doc, op); doc.history.undo(); @@ -111,7 +102,7 @@ describe('Text Undo - single op', () => { }); describe('Text Undo - chained ops', () => { - // 텍스트 내용만 읽는 헬퍼 + // read the text content const contentOf = (doc: Document<{ t: Text }>) => doc.getRoot().t.toString(); for (const op1 of ops) { @@ -140,7 +131,7 @@ describe('Text Undo - chained ops', () => { applyTextOp1(doc, op3); S.push(contentOf(doc)); // S3 - // S3 -> S2 -> S1 -> S0 순으로 되돌아가는지 확인 + // S3 -> S2 -> S1 -> S0 for (let i = 3; i >= 1; i--) { doc.history.undo(); const back = contentOf(doc); @@ -166,7 +157,6 @@ describe('Text Undo - multi client', () => { }) { 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.'); @@ -175,11 +165,9 @@ describe('Text Undo - multi client', () => { await c2.sync(); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); - // 각자 op 적용 applyTextOp1(d1, op1); applyTextOp2(d2, op2); - // 수렴 확인 await c1.sync(); await c2.sync(); await c1.sync(); @@ -189,7 +177,6 @@ describe('Text Undo - multi client', () => { 'Mismatch after both ops', ); - // 둘 다 undo d1.history.undo(); d2.history.undo(); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index e0237abbe..e2afb57ae 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -21,7 +21,6 @@ export default defineConfig({ exclude: ['**/lib/**', '**/node_modules/**'], }, setupFiles: ['./test/vitest.setup.ts'], - // setupFiles: ['../../test/vitest.setup.ts'], }, plugins: [ tsconfigPaths({ From 050c4b641f82af9a9624985db736a517e517e3f1 Mon Sep 17 00:00:00 2001 From: Hackerwins Date: Mon, 25 Aug 2025 11:24:15 +0900 Subject: [PATCH 07/14] Apply history to CodeMirror --- examples/vanilla-codemirror6/package.json | 1 + examples/vanilla-codemirror6/src/main.ts | 61 +++++++++++++++++- packages/sdk/index.html | 78 ++++++++++++++++++++++- pnpm-lock.yaml | 7 +- 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/examples/vanilla-codemirror6/package.json b/examples/vanilla-codemirror6/package.json index cfc045ddc..4df04906f 100644 --- a/examples/vanilla-codemirror6/package.json +++ b/examples/vanilla-codemirror6/package.json @@ -13,6 +13,7 @@ "vite": "^5.0.12" }, "dependencies": { + "@codemirror/view": "^6.4.1", "@codemirror/state": "^6.4.1", "codemirror": "^6.0.1", "@yorkie-js/sdk": "workspace:*" diff --git a/examples/vanilla-codemirror6/src/main.ts b/examples/vanilla-codemirror6/src/main.ts index f22df5ce9..281c6412d 100644 --- a/examples/vanilla-codemirror6/src/main.ts +++ b/examples/vanilla-codemirror6/src/main.ts @@ -1,6 +1,7 @@ import type { EditOpInfo, OperationInfo } from '@yorkie-js/sdk'; import yorkie, { DocEventType } from '@yorkie-js/sdk'; import { basicSetup, EditorView } from 'codemirror'; +import { keymap } from '@codemirror/view'; import { Transaction, TransactionSpec } from '@codemirror/state'; import { network } from './network'; import { displayLog, displayPeers } from './utils'; @@ -74,8 +75,14 @@ async function main() { } }); + // Apply both remote changes and undo/redo-driven local changes coming from Yorkie. doc.subscribe('$.content', (event) => { - if (event.type === 'remote-change') { + // `remote-change` -> changes from other peers + // `source === 'undoredo'` -> undo/redo executed via Yorkie (local but applied through history) + if ( + event.type === 'remote-change' || + (event as any).source === 'undoredo' + ) { const { operations } = event.value; handleOperations(operations); } @@ -87,7 +94,10 @@ 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']; + // Note: undo/redo keyboard handling is intercepted by a custom keymap + // so we don't treat native CM undo/redo transactions as local + // operations to send to Yorkie here. + const events = ['select', 'input', 'delete', 'move']; if (!events.map((event) => tr.isUserEvent(event)).some(Boolean)) { continue; } @@ -132,15 +142,60 @@ async function main() { }); }); + // Intercept keyboard undo/redo and route through Yorkie's history. + // This ensures multi-user undo/redo is coordinated by Yorkie and the + // local CodeMirror undo stack doesn't diverge. + const cmUndoRedoKeymap = keymap.of([ + { + key: 'Mod-z', + run: () => { + console.log('undo'); + if (doc.history.canUndo()) { + doc.history.undo(); + return true; + } + return false; + }, + }, + { + key: 'Mod-y', + run: () => { + console.log('redo'); + if (doc.history.canRedo()) { + doc.history.redo(); + return true; + } + return false; + }, + }, + { + key: 'Mod-Shift-z', + run: () => { + console.log('redo'); + if (doc.history.canRedo()) { + doc.history.redo(); + return true; + } + return false; + }, + }, + ]); + // 03-2. create codemirror instance // Height: show about 10 lines without needing actual blank text lines. // Adjust the px value if font-size/line-height changes. const fixedHeightTheme = EditorView.theme({ '.cm-content, .cm-gutter': { minHeight: '210px' }, // ~10 lines (≈21px per line including padding) }); + console.log('EditorView', EditorView); const view = new EditorView({ doc: '', - extensions: [basicSetup, fixedHeightTheme, updateListener], + extensions: [ + basicSetup, + fixedHeightTheme, + cmUndoRedoKeymap, + updateListener, + ], parent: editorParentElem, }); diff --git a/packages/sdk/index.html b/packages/sdk/index.html index 5b31e0411..90dccdb92 100644 --- a/packages/sdk/index.html +++ b/packages/sdk/index.html @@ -332,6 +332,71 @@

yorkie.setLogLevel(yorkie.LogLevel.Debug); devtool.setCodeMirror(codemirror); + // Intercept keyboard undo/redo and route through Yorkie's history. + // This ensures multi-user undo/redo is coordinated by Yorkie. + codemirror.addKeyMap({ + 'Ctrl-Z': function (cm) { + if ( + doc && + doc.history && + doc.history.canUndo && + doc.history.canUndo() + ) { + doc.history.undo(); + return true; + } + return CodeMirror.commands.undo(cm); + }, + 'Cmd-Z': function (cm) { + if ( + doc && + doc.history && + doc.history.canUndo && + doc.history.canUndo() + ) { + doc.history.undo(); + return true; + } + return CodeMirror.commands.undo(cm); + }, + 'Ctrl-Y': function (cm) { + if ( + doc && + doc.history && + doc.history.canRedo && + doc.history.canRedo() + ) { + doc.history.redo(); + return true; + } + return CodeMirror.commands.redo(cm); + }, + 'Shift-Ctrl-Z': function (cm) { + if ( + doc && + doc.history && + doc.history.canRedo && + doc.history.canRedo() + ) { + doc.history.redo(); + return true; + } + return CodeMirror.commands.redo(cm); + }, + 'Shift-Cmd-Z': function (cm) { + if ( + doc && + doc.history && + doc.history.canRedo && + doc.history.canRedo() + ) { + doc.history.redo(); + return true; + } + return CodeMirror.commands.redo(cm); + }, + }); + // 02-1. create client with RPCAddr. const client = new yorkie.Client({ rpcAddr: 'http://localhost:8080', @@ -383,7 +448,8 @@

}); doc.subscribe('$.content', (event) => { - if (event.type === 'remote-change') { + // Handle both remote changes and undo/redo-applied changes. + if (event.type === 'remote-change' || event.source === 'undoredo') { const { actor, operations } = event.value; handleOperations(operations, actor); @@ -401,7 +467,15 @@

// 04. bind the document with the codemirror. // 04-1. codemirror to document(applying local). codemirror.on('beforeChange', (cm, change) => { - if (change.origin === 'yorkie' || change.origin === 'setValue') { + // Ignore changes that originated from Yorkie sync or programmatic setValue. + // Also ignore CodeMirror's undo/redo origins as those are routed + // through Yorkie's history to keep multi-user history consistent. + if ( + change.origin === 'yorkie' || + change.origin === 'setValue' || + change.origin === 'undo' || + change.origin === 'redo' + ) { return; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79db927da..35c3e9bfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,10 +131,6 @@ importers: specifier: 5.3.3 version: 5.3.3 - examples/nextjs-scheduler/dist: {} - - examples/nextjs-scheduler/dist/types: {} - examples/nextjs-todolist: dependencies: '@radix-ui/react-slot': @@ -426,6 +422,9 @@ importers: '@codemirror/state': specifier: ^6.4.1 version: 6.5.2 + '@codemirror/view': + specifier: ^6.4.1 + version: 6.38.1 '@yorkie-js/sdk': specifier: workspace:* version: link:../../packages/sdk From dd9d98a8cf7923a1d7b33bc5d63290635ace7e41 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 15 Sep 2025 12:34:34 +0900 Subject: [PATCH 08/14] reimplement undo feature. use another Reconcile function instead of GCLock --- .../sdk/src/document/crdt/rga_tree_split.ts | 51 ++++++ packages/sdk/src/document/crdt/text.ts | 7 + packages/sdk/src/document/document.ts | 25 +++ packages/sdk/src/document/history.ts | 25 +++ .../src/document/operation/edit_operation.ts | 149 +++++++++++++++++- 5 files changed, 251 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/document/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index 334ccb617..33d8c66a0 100644 --- a/packages/sdk/src/document/crdt/rga_tree_split.ts +++ b/packages/sdk/src/document/crdt/rga_tree_split.ts @@ -731,6 +731,57 @@ export class RGATreeSplit implements GCParent { return this.treeByID; } + /** + * `normalizePos` converts a local position `(id, rel)` into a single + * absolute offset measured from the head `(0:0)` of the physical chain. + * + * - Traverses the physical `prev` chain (not `insPrev`) from the start node + * toward the head. + * - Counts only live characters: removed/tombstoned nodes contribute length 0. + * - Sums the live content length of every predecessor node and finally adds + * the input `rel` of the start node. + * - Returns a position anchored at the head with the computed absolute offset. + * + * Invariants & assumptions: + * - Works across arbitrary split boundaries; GC may remove linkage like + * insert-predecessors, but the physical `prev` chain is used exclusively. + * - The head node `(0:0)` is a zero-length origin; it contributes no content. + * - If the given `id` cannot be resolved to a node, an error is thrown. + * - If `(id, rel)` is potentially stale (e.g., after splits/deletions), + * callers should first reconcile it with `refinePos` before normalizing. + * + * Example: + * Chain: |"12" 1:0| - |"A" 2:1| - |"34" 1:2| + * normalizePos((1:2, rel=1)) -> (0:0, 4) + * // because |"12"|=2 + |"A"|=1 + rel(1) = 4 + * + * Example (skipping removed): + * Chain: |"abc" 1:0| - |"A" 2:1| - |"de" 3:0| + * normalizePos((3:0, rel=2)) -> (0:0, 5) + * // |"abc"|=3 + |removed|=0 + rel(2) = 5 + */ + public normalizePos(pos: RGATreeSplitPos): RGATreeSplitPos { + 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(curr.getID(), total); + } + /** * `refinePos` remaps the given pos to the current split chain. * diff --git a/packages/sdk/src/document/crdt/text.ts b/packages/sdk/src/document/crdt/text.ts index ae0ea54b0..38f49393d 100644 --- a/packages/sdk/src/document/crdt/text.ts +++ b/packages/sdk/src/document/crdt/text.ts @@ -426,6 +426,13 @@ export class CRDTText extends CRDTElement { return this.rgaTreeSplit.refinePos(pos); } + /** + * `normalizePos` normalizes the given RGATreeSplitPos. + */ + public normalizePos(pos: RGATreeSplitPos): RGATreeSplitPos { + return this.rgaTreeSplit.normalizePos(pos); + } + /** * `toJSON` returns the JSON encoding of this text. */ diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index ebc5d6a08..f3a1d3f4e 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -80,6 +80,7 @@ import { setupDevtools } from '@yorkie-js/sdk/src/devtools'; import * as Devtools from '@yorkie-js/sdk/src/devtools/types'; import { VersionVector } from './time/version_vector'; import { DocSize, totalDocSize } from '@yorkie-js/sdk/src/util/resource'; +import { EditOperation } from './operation/edit_operation'; /** * `BroadcastOptions` are the options to create a new document. @@ -799,6 +800,14 @@ export class Document { op.getCreatedAt(), op.getValue().getCreatedAt(), ); + } else if (op instanceof EditOperation) { + const [rangeFrom, rangeTo] = op.normalizePos(this.root); + this.internalHistory.reconcileTextEdit( + op.getParentCreatedAt(), + rangeFrom, + rangeTo, + op.getContent().length, + ); } } @@ -2104,6 +2113,14 @@ export class Document { const prev = undoOp.getValue().getCreatedAt(); undoOp.getValue().setCreatedAt(ticket); this.internalHistory.reconcileCreatedAt(prev, ticket); + } else if (undoOp instanceof EditOperation) { + const [rangeFrom, rangeTo] = undoOp.normalizePos(this.root); + this.internalHistory.reconcileTextEdit( + undoOp.getParentCreatedAt(), + rangeFrom, + rangeTo, + undoOp.getContent().length, + ); } context.push(undoOp); @@ -2220,6 +2237,14 @@ export class Document { const prev = redoOp.getValue().getCreatedAt(); redoOp.getValue().setCreatedAt(ticket); this.internalHistory.reconcileCreatedAt(prev, ticket); + } else if (redoOp instanceof EditOperation) { + const [rangeFrom, rangeTo] = redoOp.normalizePos(this.root); + this.internalHistory.reconcileTextEdit( + redoOp.getParentCreatedAt(), + rangeFrom, + rangeTo, + redoOp.getContent().length, + ); } context.push(redoOp); diff --git a/packages/sdk/src/document/history.ts b/packages/sdk/src/document/history.ts index 832d77467..3d107d106 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,28 @@ export class History

{ replace(this.undoStack); replace(this.redoStack); } + + /** + */ + public reconcileTextEdit( + parentCreatedAt: TimeTicket, + rangeFrom: number, + rangeTo: number, + contentLen: 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, contentLen); + } + } + } + }; + 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 4cf9477a2..f1261a9b6 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -102,7 +102,11 @@ export class EditOperation extends Operation { } const text = parentObject as CRDTText; - if (this.isUndoOp && !this.fromPos.equals(this.toPos)) { + if (this.isUndoOp) { + console.log( + `##### from=${this.fromPos.getRelativeOffset()}, to=${this.toPos.getRelativeOffset()}}`, + ); + this.fromPos = text.refinePos(this.fromPos); this.toPos = text.refinePos(this.toPos); } const [changes, pairs, diff, , removed] = text.edit( @@ -113,7 +117,10 @@ export class EditOperation extends Operation { versionVector, ); - const reverseOp = this.toReverseOperation(removed); + const reverseOp = this.toReverseOperation( + removed, + text.normalizePos(this.fromPos), + ); root.acc(diff); for (const pair of pairs) { @@ -150,6 +157,7 @@ export class EditOperation extends Operation { private toReverseOperation( removedValues: Array, + normalizedPos: RGATreeSplitPos, ): Operation | undefined { // 1) Content const restoredContent = @@ -157,7 +165,7 @@ export class EditOperation extends Operation { ? removedValues.map((v) => v.getContent()).join('') : ''; - // 2) Arttibute + // 2) Attribute let restoredAttrs: Array<[string, string]> | undefined; if (removedValues.length === 1) { const attrsObj = removedValues[0].getAttributes(); @@ -166,13 +174,17 @@ export class EditOperation extends Operation { } } + console.log( + `%%%%% from=${normalizedPos.getRelativeOffset()}, to=${normalizedPos.getRelativeOffset() + (this.content?.length ?? 0)}}`, + ); + // 3) Create Reverse Operation return EditOperation.create( this.getParentCreatedAt(), - this.fromPos, + normalizedPos, RGATreeSplitPos.of( - this.fromPos.getID(), - this.fromPos.getRelativeOffset() + (this.content?.length ?? 0), + normalizedPos.getID(), + normalizedPos.getRelativeOffset() + (this.content?.length ?? 0), ), restoredContent, restoredAttrs ? new Map(restoredAttrs) : new Map(), @@ -181,6 +193,131 @@ export class EditOperation extends Operation { ); } + /** + * Normalize the current edit operation's [fromPos,toPos] into absolute offsets. + * + * - Looks up the parent object in the given `CRDTRoot` by `parentCreatedAt`. + * - Verifies that the parent is a `CRDTText`; otherwise throws an error. + * - Calls `text.normalizePos` for both `fromPos` and `toPos` to convert each + * local `(id, relOffset)` into a global offset measured from the head `(0:0)`. + * - Returns the normalized range as a tuple `[rangeFrom, rangeTo]`. + * + * @typeParam A - The element type of the CRDTText (extends Indexable). + * @param root - The CRDTRoot containing the CRDTText this operation belongs to. + * @returns A two-element array `[rangeFrom, rangeTo]` representing the absolute + * offsets of this operation's start and end positions. + * @throws {YorkieError} If the parent object cannot be found or is not a CRDTText. + */ + 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, + `fail to execute, 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]; + } + + /** + * Reconcile this UNDO edit's local range [a,b) (relative offsets on current IDs) + * against an external local range [rangeFrom, rangeTo) (relative offsets), and + * mutate this.fromPos/toPos accordingly. + * + * Rules (executed in this order): + * 6) [rangeFrom,rangeTo) @ [a,b) -> a=b=rangeFrom + * 1) [rangeFrom,rangeTo) strictly before [a,b) -> a-=len, b-=len + * 5) [rangeFrom,rangeTo) strictly after [a,b) -> no change + * 2) rangeFrom < a < rangeTo < b -> a=rangeFrom, b=rangeFrom+(b-rangeTo) + * 3) [rangeFrom,rangeTo) c [a,b) -> b-=len + * 4) a < rangeFrom < b and b < rangeTo -> b=rangeFrom + * + * Notes: + * - Runs only if this.isUndoOp === true; otherwise no-op. + * - Assumes integer inputs with rangeFrom < rangeTo. + * - Keeps node IDs; adjusts relative offsets only. Offsets are clamped to ≥ 0. + */ + public reconcileOperation( + rangeFrom: number, + rangeTo: number, + contentLen: number, + ): void { + if (!this.isUndoOp) { + console.log('[skip] not an undo op'); + return; + } + if (!Number.isInteger(rangeFrom) || !Number.isInteger(rangeTo)) { + console.log('[skip] invalid args', { rangeFrom, rangeTo }); + return; + } + if (rangeFrom > rangeTo) { + console.log('[skip] invalid range order', { rangeFrom, rangeTo }); + return; + } + + const rangeLen = rangeTo - rangeFrom; + const a = this.fromPos.getRelativeOffset(); + const b = this.toPos.getRelativeOffset(); + + const apply = (na: number, nb: number, label: string) => { + console.log(`[apply-${label}] before`, { a, b }, 'range', { + rangeFrom, + rangeTo, + contentLen, + }); + this.fromPos = RGATreeSplitPos.of(this.fromPos.getID(), Math.max(0, na)); + this.toPos = RGATreeSplitPos.of(this.toPos.getID(), Math.max(0, nb)); + console.log(`[apply-${label}] after`, { + from: this.fromPos.getRelativeOffset(), + to: this.toPos.getRelativeOffset(), + }); + }; + + // Fully overlap: contains + if (rangeFrom <= a && b <= rangeTo && rangeFrom !== rangeTo) { + apply(rangeFrom, rangeFrom, 'contains-left'); + return; + } + if (a <= rangeFrom && rangeTo <= b && a !== b) { + apply(a, b - rangeLen + contentLen, 'contains-right'); + return; + } + + // Does not overlap + if (rangeTo <= a) { + apply(a - rangeLen + contentLen, b - rangeLen + contentLen, 'before'); + return; + } + if (b <= rangeFrom) { + console.log('[no-change] range after op', { a, b }); + return; + } + + // overlap at the start + if (rangeFrom < a && a < rangeTo && rangeTo < b) { + apply(rangeFrom, rangeFrom + (b - rangeTo), 'overlap-start'); + return; + } + + // overlap at the end + if (a < rangeFrom && rangeFrom < b && b < rangeTo) { + apply(a, rangeFrom, 'overlap-end'); + return; + } + + console.log('[no-match] no case applied', { a, b, rangeFrom, rangeTo }); + } + /** * `getEffectedCreatedAt` returns the creation time of the effected element. */ From 2d7f2a630282b66c9db89cbf690b0c28ba73ee32 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 15 Sep 2025 12:48:06 +0900 Subject: [PATCH 09/14] resolve error due to merging --- packages/sdk/src/document/document.ts | 73 ++------------------------- 1 file changed, 4 insertions(+), 69 deletions(-) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index 44fe7af96..feb1d562c 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -1906,71 +1906,6 @@ export class Document { }, ]); } - - private undo(): void { - if (this.isUpdating) { - throw new YorkieError( - Code.ErrRefused, - 'Undo is not allowed during an update', - ); - } - const undoOps = this.internalHistory.popUndo(); - if (undoOps === undefined) { - throw new YorkieError( - Code.ErrRefused, - 'There is no operation to be undone', - ); - } - - this.ensureClone(); - // TODO(chacha912): After resolving the presence initialization issue, - // remove default presence.(#608) - const context = ChangeContext.create

( - this.changeID, - this.clone!.root, - this.clone!.presences.get(this.changeID.getActorID()) || ({} as P), - ); - - // apply undo operation in the context to generate a change - for (const undoOp of undoOps) { - if (!(undoOp instanceof Operation)) { - // apply presence change to the context - const presence = new Presence

( - context, - deepcopy(this.clone!.presences.get(this.changeID.getActorID())!), - ); - presence.set(undoOp.value, { addToHistory: true }); - continue; - } - - const ticket = context.issueTimeTicket(); - undoOp.setExecutedAt(ticket); - - // NOTE(hackerwins): In undo/redo, both Set and Add may act as updates. - // - Set: replaces the element value. - // - Add: serves as UndoRemove, effectively restoring a deleted element. - // In both cases, the history stack may point to the old createdAt, - // so we update it to the new createdAt. - if (undoOp instanceof ArraySetOperation) { - const prev = undoOp.getCreatedAt(); - undoOp.getValue().setCreatedAt(ticket); - this.internalHistory.reconcileCreatedAt(prev, ticket); - } else if (undoOp instanceof AddOperation) { - const prev = undoOp.getValue().getCreatedAt(); - undoOp.getValue().setCreatedAt(ticket); - this.internalHistory.reconcileCreatedAt(prev, ticket); - } else if (undoOp instanceof EditOperation) { - const [rangeFrom, rangeTo] = undoOp.normalizePos(this.root); - this.internalHistory.reconcileTextEdit( - undoOp.getParentCreatedAt(), - rangeFrom, - rangeTo, - undoOp.getContent().length, - ); - } - - context.push(undoOp); - } /** * `getVersionVector` returns the version vector of document @@ -2063,13 +1998,13 @@ export class Document { const prev = op.getValue().getCreatedAt(); op.getValue().setCreatedAt(ticket); this.internalHistory.reconcileCreatedAt(prev, ticket); - } else if (redoOp instanceof EditOperation) { - const [rangeFrom, rangeTo] = redoOp.normalizePos(this.root); + } else if (op instanceof EditOperation) { + const [rangeFrom, rangeTo] = op.normalizePos(this.root); this.internalHistory.reconcileTextEdit( - redoOp.getParentCreatedAt(), + op.getParentCreatedAt(), rangeFrom, rangeTo, - redoOp.getContent().length, + op.getContent().length, ); } From 56e0eb9af9e0cf71b39e425e6382a3f1090e93d8 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 15 Sep 2025 12:50:20 +0900 Subject: [PATCH 10/14] resolve error --- packages/sdk/src/document/document.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index feb1d562c..0eae2b7b2 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -19,7 +19,6 @@ import { converter } from '@yorkie-js/sdk/src/api/converter'; import { logger, LogLevel } from '@yorkie-js/sdk/src/util/logger'; import { Code, YorkieError } from '@yorkie-js/sdk/src/util/error'; import { deepcopy } from '@yorkie-js/sdk/src/util/object'; -import { DocSize, totalDocSize } from '@yorkie-js/sdk/src/util/resource'; import { Observer, Observable, @@ -33,7 +32,6 @@ import { ActorID, InitialActorID, } from '@yorkie-js/sdk/src/document/time/actor_id'; -import { VersionVector } from '@yorkie-js/sdk/src/document/time/version_vector'; import { Change, ChangeStruct, From b93aa3617699cdef66e35f8a6da634a7c346aa94 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 15 Sep 2025 13:32:14 +0900 Subject: [PATCH 11/14] test --- packages/sdk/src/document/document.ts | 36 ++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index 0eae2b7b2..d3e89c132 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -82,7 +82,7 @@ import { setupDevtools } from '@yorkie-js/sdk/src/devtools'; import * as Devtools from '@yorkie-js/sdk/src/devtools/types'; import { VersionVector } from './time/version_vector'; import { DocSize, totalDocSize } from '@yorkie-js/sdk/src/util/resource'; -import { EditOperation } from './operation/edit_operation'; +// import { EditOperation } from './operation/edit_operation'; /** * `BroadcastOptions` are the options to create a new document. @@ -706,15 +706,16 @@ export class Document { op.getCreatedAt(), op.getValue().getCreatedAt(), ); - } else if (op instanceof EditOperation) { - const [rangeFrom, rangeTo] = op.normalizePos(this.root); - this.internalHistory.reconcileTextEdit( - op.getParentCreatedAt(), - rangeFrom, - rangeTo, - op.getContent().length, - ); } + // else if (op instanceof EditOperation) { + // const [rangeFrom, rangeTo] = op.normalizePos(this.root); + // this.internalHistory.reconcileTextEdit( + // op.getParentCreatedAt(), + // rangeFrom, + // rangeTo, + // op.getContent()?.length ?? 0, + // ); + // } } const reversePresence = ctx.getReversePresence(); @@ -1996,15 +1997,16 @@ export class Document { const prev = op.getValue().getCreatedAt(); op.getValue().setCreatedAt(ticket); this.internalHistory.reconcileCreatedAt(prev, ticket); - } else if (op instanceof EditOperation) { - const [rangeFrom, rangeTo] = op.normalizePos(this.root); - this.internalHistory.reconcileTextEdit( - op.getParentCreatedAt(), - rangeFrom, - rangeTo, - op.getContent().length, - ); } + // else if (op instanceof EditOperation) { + // const [rangeFrom, rangeTo] = op.normalizePos(this.root); + // this.internalHistory.reconcileTextEdit( + // op.getParentCreatedAt(), + // rangeFrom, + // rangeTo, + // op.getContent().length, + // ); + // } ctx.push(op); } From ce7368923a4019bef925d6e7cd465060c5d9c186 Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 15 Sep 2025 13:50:17 +0900 Subject: [PATCH 12/14] resolve some issue and fix potention array undo bug --- packages/sdk/src/document/document.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index d3e89c132..6282a0a18 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -82,7 +82,7 @@ import { setupDevtools } from '@yorkie-js/sdk/src/devtools'; import * as Devtools from '@yorkie-js/sdk/src/devtools/types'; import { VersionVector } from './time/version_vector'; import { DocSize, totalDocSize } from '@yorkie-js/sdk/src/util/resource'; -// import { EditOperation } from './operation/edit_operation'; +import { EditOperation } from './operation/edit_operation'; /** * `BroadcastOptions` are the options to create a new document. @@ -1524,6 +1524,23 @@ export class Document { } const { opInfos } = change.execute(this.root, this.presences, source); + for (const op of change.getOperations()) { + if (op instanceof ArraySetOperation) { + this.internalHistory.reconcileCreatedAt( + op.getCreatedAt(), + op.getValue().getCreatedAt(), + ); + } else 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; @@ -2004,7 +2021,7 @@ export class Document { // op.getParentCreatedAt(), // rangeFrom, // rangeTo, - // op.getContent().length, + // op.getContent()?.length ?? 0, // ); // } From 6cc2296ca82111e812ba8581e11e7a71fb43f9eb Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Mon, 15 Sep 2025 13:58:22 +0900 Subject: [PATCH 13/14] resolve errors --- .../src/document/operation/edit_operation.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index 06b78482a..e72440eeb 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -283,16 +283,6 @@ export class EditOperation extends Operation { }); }; - // Fully overlap: contains - if (rangeFrom <= a && b <= rangeTo && rangeFrom !== rangeTo) { - apply(rangeFrom, rangeFrom, 'contains-left'); - return; - } - if (a <= rangeFrom && rangeTo <= b && a !== b) { - apply(a, b - rangeLen + contentLen, 'contains-right'); - return; - } - // Does not overlap if (rangeTo <= a) { apply(a - rangeLen + contentLen, b - rangeLen + contentLen, 'before'); @@ -303,6 +293,16 @@ export class EditOperation extends Operation { return; } + // Fully overlap: contains + if (rangeFrom <= a && b <= rangeTo && rangeFrom !== rangeTo) { + apply(rangeFrom, rangeFrom, 'contains-left'); + return; + } + if (a <= rangeFrom && rangeTo <= b && a !== b) { + apply(a, b - rangeLen + contentLen, 'contains-right'); + return; + } + // overlap at the start if (rangeFrom < a && a < rangeTo && rangeTo < b) { apply(rangeFrom, rangeFrom + (b - rangeTo), 'overlap-start'); From dc264544e0d571c3fc81939a99ab6381c8353eba Mon Sep 17 00:00:00 2001 From: Kang Myeong Seok Date: Tue, 16 Sep 2025 17:14:54 +0900 Subject: [PATCH 14/14] resolve reviews --- packages/sdk/index.html | 77 +++++-------------- packages/sdk/src/document/document.ts | 18 ----- packages/sdk/src/document/history.ts | 16 ++++ .../src/document/operation/edit_operation.ts | 34 ++------ 4 files changed, 43 insertions(+), 102 deletions(-) diff --git a/packages/sdk/index.html b/packages/sdk/index.html index 0f3ed3fa9..81da828ec 100644 --- a/packages/sdk/index.html +++ b/packages/sdk/index.html @@ -331,70 +331,35 @@

yorkie.setLogLevel(yorkie.LogLevel.Debug); devtool.setCodeMirror(codemirror); - // Intercept keyboard undo/redo and route through Yorkie's history. - // This ensures multi-user undo/redo is coordinated by Yorkie. - codemirror.addKeyMap({ - 'Ctrl-Z': function (cm) { - if ( - doc && - doc.history && - doc.history.canUndo && - doc.history.canUndo() - ) { + function createUndoRedoKeyMap(doc) { + const undoHandler = function (cm) { + if (doc?.history?.canUndo()) { doc.history.undo(); return true; } return CodeMirror.commands.undo(cm); - }, - 'Cmd-Z': function (cm) { - if ( - doc && - doc.history && - doc.history.canUndo && - doc.history.canUndo() - ) { - doc.history.undo(); - return true; - } - return CodeMirror.commands.undo(cm); - }, - 'Ctrl-Y': function (cm) { - if ( - doc && - doc.history && - doc.history.canRedo && - doc.history.canRedo() - ) { - doc.history.redo(); - return true; - } - return CodeMirror.commands.redo(cm); - }, - 'Shift-Ctrl-Z': function (cm) { - if ( - doc && - doc.history && - doc.history.canRedo && - doc.history.canRedo() - ) { - doc.history.redo(); - return true; - } - return CodeMirror.commands.redo(cm); - }, - 'Shift-Cmd-Z': function (cm) { - if ( - doc && - doc.history && - doc.history.canRedo && - doc.history.canRedo() - ) { + } + + const redoHandler = function (cm) { + if (doc?.history?.canRedo()) { doc.history.redo(); return true; } return CodeMirror.commands.redo(cm); - }, - }); + } + + return { + 'Ctrl-Z': undoHandler, + 'Cmd-Z': undoHandler, + 'Ctrl-Y': redoHandler, + 'Shift-Ctrl-Z': redoHandler, + 'Shift-Cmd-Z': redoHandler, + }; + } + + // Intercept keyboard undo/redo and route through Yorkie's history. + // This ensures multi-user undo/redo is coordinated by Yorkie. + codemirror.addKeyMap(createUndoRedoKeyMap(doc)); // 02-1. create client with RPCAddr. const client = new yorkie.Client({ diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index 6282a0a18..992aea450 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -707,15 +707,6 @@ export class Document { op.getValue().getCreatedAt(), ); } - // else if (op instanceof EditOperation) { - // const [rangeFrom, rangeTo] = op.normalizePos(this.root); - // this.internalHistory.reconcileTextEdit( - // op.getParentCreatedAt(), - // rangeFrom, - // rangeTo, - // op.getContent()?.length ?? 0, - // ); - // } } const reversePresence = ctx.getReversePresence(); @@ -2015,15 +2006,6 @@ export class Document { op.getValue().setCreatedAt(ticket); this.internalHistory.reconcileCreatedAt(prev, ticket); } - // else if (op instanceof EditOperation) { - // const [rangeFrom, rangeTo] = op.normalizePos(this.root); - // this.internalHistory.reconcileTextEdit( - // op.getParentCreatedAt(), - // rangeFrom, - // rangeTo, - // op.getContent()?.length ?? 0, - // ); - // } ctx.push(op); } diff --git a/packages/sdk/src/document/history.ts b/packages/sdk/src/document/history.ts index 3d107d106..cf1078d55 100644 --- a/packages/sdk/src/document/history.ts +++ b/packages/sdk/src/document/history.ts @@ -153,6 +153,22 @@ export class History

{ } /** + * Reconcile stored text `EditOperation`s in undo/redo stacks against a recent edit. + * + * Scans both `undoStack` and `redoStack`, and for every `EditOperation` whose + * parent text matches `parentCreatedAt`, invokes `op.reconcileOperation(rangeFrom, rangeTo, contentLen)`. + * This adjusts each operation’s `(fromPos, toPos)` relative offsets so they remain + * valid after another edit has modified the document. + * + * @param parentCreatedAt - The creation time of the target `CRDTText` whose edits should be reconciled. + * @param rangeFrom - Start offset of the applied edit range `[rangeFrom, rangeTo)` (integer). + * @param rangeTo - End offset (exclusive) of the applied edit range `[rangeFrom, rangeTo)` (integer). + * @param contentLen - The length of the newly inserted content that replaced `[rangeFrom, rangeTo)`. + * + * @remarks + * - Complexity is O(total number of stored operations) across both stacks. + * - This method does not normalize coordinates; callers must pass offsets that + * match the coordinate system used by stored `EditOperation`s. */ public reconcileTextEdit( parentCreatedAt: TimeTicket, diff --git a/packages/sdk/src/document/operation/edit_operation.ts b/packages/sdk/src/document/operation/edit_operation.ts index e72440eeb..d017567d4 100644 --- a/packages/sdk/src/document/operation/edit_operation.ts +++ b/packages/sdk/src/document/operation/edit_operation.ts @@ -103,9 +103,6 @@ export class EditOperation extends Operation { const text = parentObject as CRDTText; if (this.isUndoOp) { - console.log( - `##### from=${this.fromPos.getRelativeOffset()}, to=${this.toPos.getRelativeOffset()}}`, - ); this.fromPos = text.refinePos(this.fromPos); this.toPos = text.refinePos(this.toPos); } @@ -174,10 +171,6 @@ export class EditOperation extends Operation { } } - console.log( - `%%%%% from=${normalizedPos.getRelativeOffset()}, to=${normalizedPos.getRelativeOffset() + (this.content?.length ?? 0)}}`, - ); - // 3) Create Reverse Operation return EditOperation.create( this.getParentCreatedAt(), @@ -253,15 +246,12 @@ export class EditOperation extends Operation { contentLen: number, ): void { if (!this.isUndoOp) { - console.log('[skip] not an undo op'); return; } if (!Number.isInteger(rangeFrom) || !Number.isInteger(rangeTo)) { - console.log('[skip] invalid args', { rangeFrom, rangeTo }); return; } if (rangeFrom > rangeTo) { - console.log('[skip] invalid range order', { rangeFrom, rangeTo }); return; } @@ -269,53 +259,41 @@ export class EditOperation extends Operation { const a = this.fromPos.getRelativeOffset(); const b = this.toPos.getRelativeOffset(); - const apply = (na: number, nb: number, label: string) => { - console.log(`[apply-${label}] before`, { a, b }, 'range', { - rangeFrom, - rangeTo, - contentLen, - }); + 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)); - console.log(`[apply-${label}] after`, { - from: this.fromPos.getRelativeOffset(), - to: this.toPos.getRelativeOffset(), - }); }; // Does not overlap if (rangeTo <= a) { - apply(a - rangeLen + contentLen, b - rangeLen + contentLen, 'before'); + apply(a - rangeLen + contentLen, b - rangeLen + contentLen); return; } if (b <= rangeFrom) { - console.log('[no-change] range after op', { a, b }); return; } // Fully overlap: contains if (rangeFrom <= a && b <= rangeTo && rangeFrom !== rangeTo) { - apply(rangeFrom, rangeFrom, 'contains-left'); + apply(rangeFrom, rangeFrom); return; } if (a <= rangeFrom && rangeTo <= b && a !== b) { - apply(a, b - rangeLen + contentLen, 'contains-right'); + apply(a, b - rangeLen + contentLen); return; } // overlap at the start if (rangeFrom < a && a < rangeTo && rangeTo < b) { - apply(rangeFrom, rangeFrom + (b - rangeTo), 'overlap-start'); + apply(rangeFrom, rangeFrom + (b - rangeTo)); return; } // overlap at the end if (a < rangeFrom && rangeFrom < b && b < rangeTo) { - apply(a, rangeFrom, 'overlap-end'); + apply(a, rangeFrom); return; } - - console.log('[no-match] no case applied', { a, b, rangeFrom, rangeTo }); } /**