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..42cfe37ce 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'; @@ -48,36 +49,13 @@ 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') { const { operations } = event.value; - handleOperations(operations); + handleOperations(operations, false); + } else if (event.source === 'undoredo') { + const { operations } = event.value; + handleOperations(operations, true); } }); @@ -87,7 +65,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,21 +116,73 @@ 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-z', + preventDefault: true, + run: () => { + if (doc.history.canUndo()) { + doc.history.undo(); + } + return true; + }, + }, + { + key: 'Mod-Shift-z', + preventDefault: true, + run: () => { + if (doc.history.canRedo()) { + doc.history.redo(); + } + return true; + }, + }, + ]); const view = new EditorView({ doc: '', - extensions: [basicSetup, fixedHeightTheme, updateListener], + 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], + }; + } + 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) { + 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), @@ -161,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(); 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/crdt/rga_tree_split.ts b/packages/sdk/src/document/crdt/rga_tree_split.ts index 979a35e0d..75f9bc368 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,79 @@ 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 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. + * + * - 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/document.ts b/packages/sdk/src/document/document.ts index 6316e1f6a..0345982b9 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. @@ -1335,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, @@ -1350,6 +1352,11 @@ export class Document< ]); } + private clearHistory() { + this.internalHistory.clearRedo(); + this.internalHistory.clearUndo(); + } + /** * `applyChanges` applies the given changes into this document. */ @@ -1443,7 +1450,23 @@ export class Document< } } - const { opInfos } = change.execute(this.root, this.presences, source); + 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, + to, + 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..1d2c007c0 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. @@ -98,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. */ @@ -127,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 ( @@ -150,4 +159,31 @@ 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>>) => { + // TODO(hackerwins): Optimize by indexing operations. + 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 3e151a05a..46796f1c0 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,13 @@ export class EditOperation extends Operation { } const text = parentObject as CRDTText; - const [changes, pairs, diff] = text.edit( + + 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, this.getExecutedAt(), @@ -105,6 +116,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,7 +136,157 @@ export class EditOperation extends Operation { path: root.createPath(this.getParentCreatedAt()), } as OpInfo; }), + reverseOp, + }; + } + + private toReverseOperation( + removedValues: Array, + fromPos: RGATreeSplitPos, + ): Operation { + 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) { + attrs = Array.from(Object.entries(attrsObj as any)); + } + } + + return EditOperation.create( + this.getParentCreatedAt(), + fromPos, + RGATreeSplitPos.of( + fromPos.getID(), + fromPos.getRelativeOffset() + (this.content?.length ?? 0), + ), + content, + attrs ? new Map(attrs) : new Map(), + undefined, + true, + ); + } + + /** + * `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 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( + remoteFrom: number, + remoteTo: number, + contentLen: number, + ): void { + if (!this.isUndoOp) { + return; + } + if (remoteFrom > remoteTo) { + return; + } + + 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)); }; + + // Case 1: Remote edit is to the left of undo range + // [--remote--] [--undo--] + if (remoteTo <= localFrom) { + apply( + localFrom - remoteRangeLen + contentLen, + localTo - remoteRangeLen + contentLen, + ); + return; + } + + // Case 2: Remote edit is to the right of undo range + // [--undo--] [--remote--] + if (localTo <= remoteFrom) { + return; + } + + // Case 3: Undo range is contained within remote range + // [-------remote-------] + // [--undo--] + if ( + remoteFrom <= localFrom && + localTo <= remoteTo && + remoteFrom !== remoteTo + ) { + apply(remoteFrom, remoteFrom); + return; + } + + // Case 4: Remote range is contained within undo range + // [--remote--] + // [---------undo---------] + if ( + localFrom <= remoteFrom && + remoteTo <= localTo && + localFrom !== localTo + ) { + apply(localFrom, localTo - remoteRangeLen + contentLen); + return; + } + + // 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; + } + + // 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 new file mode 100644 index 000000000..2c8aa5ee7 --- /dev/null +++ b/packages/sdk/test/integration/history_text_test.ts @@ -0,0 +1,831 @@ +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: + * ┌─────────────────┬────────────────────────────────────────────────────────┐ + * │ 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} │ + * └─────────────────┴────────────────────────────────────────────────────────┘ + */ +type TextOp = 'insert' | 'delete' | 'replace' | 'style'; +const ops: Array = ['insert', 'delete', 'replace']; + +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': + t.edit(len, len, 'X'); + break; + case 'delete': + if (len >= 3) t.edit(1, 2, ''); + else if (len > 0) t.edit(0, 1, ''); + break; + case 'replace': + if (len >= 3) t.edit(1, 3, '12'); + else t.edit(0, Math.min(1, len), 'R'); + break; + case 'style': + if (len === 0) t.edit(0, 0, 'A'); + t.setStyle(0, t.length ?? t.toString().length, { bold: true }); + break; + } + }, op); +} + +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': + t.edit(0, 0, 'Q'); + break; + case 'delete': + if (len > 0) t.edit(len - 1, len, ''); + break; + case 'replace': + if (len > 0) t.edit(0, 1, 'Z'); + else t.edit(0, 0, 'Z'); + break; + } + }, 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'); + doc.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'The fox jumped.'); + }, 'init'); + + const before = contentOf(doc); + applyTextOp1(doc, op); + const after = contentOf(doc); + + doc.history.undo(); + assert.equal(contentOf(doc), before, `undo ${op} failed`); + + doc.history.redo(); + assert.equal(contentOf(doc), after, `redo ${op} failed`); + }); + } + + 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) => { + 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); + + doc.history.undo(); + assert.equal(doc.toSortedJSON(), initialJSON); + + doc.history.redo(); + assert.equal(doc.toSortedJSON(), styledJSON); + }); +}); + +describe('Text History - single client chained ops', () => { + 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 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 snapshots: Array = [contentOf(doc)]; + applyTextOp1(doc, op1); + snapshots.push(contentOf(doc)); + applyTextOp1(doc, op2); + snapshots.push(contentOf(doc)); + applyTextOp1(doc, op3); + snapshots.push(contentOf(doc)); + + // Undo: S3 → S2 → S1 → S0 + for (let i = 3; i >= 1; i--) { + doc.history.undo(); + 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}`); + } + }); + } + } + } +}); + +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) { + 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(); + + 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) => { + 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(); + + d1.history.undo(); + d2.history.undo(); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + 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); + }); + } + } +}); + +/** + * 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, + }) => { + 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 [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()); + + d1.history.redo(); + d2.history.redo(); + 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()); + + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + 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); + }); + + 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) => { + 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: 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()); + + d1.history.redo(); + d2.history.redo(); + 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 = new Text(); + root.t.edit(0, 0, '0123456789'); + }, 'init'); + await c1.sync(); + await c2.sync(); + + // 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()); + + d1.history.redo(); + d2.history.redo(); + 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(); + + // 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()); + + d1.history.redo(); + d2.history.redo(); + 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()); + + d1.history.redo(); + d2.history.redo(); + 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()); + + d1.history.undo(); + d2.history.undo(); + await c1.sync(); + 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); + }); +}); + +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) => { + 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(); + + await c1.sync(); + await c2.sync(); + 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 = 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'); + + await c1.sync(); + await c2.sync(); + await c1.sync(); + + d1.history.undo(); + d2.history.undo(); + + await c1.sync(); + 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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d032dbd4..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': @@ -410,6 +404,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 @@ -12578,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: @@ -12600,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