Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/vanilla-codemirror6/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand Down
61 changes: 58 additions & 3 deletions examples/vanilla-codemirror6/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { EditOpInfo, OpInfo } 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';
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
});

Expand Down
78 changes: 76 additions & 2 deletions packages/sdk/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,71 @@ <h4 class="title">
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',
Expand Down Expand Up @@ -382,7 +447,8 @@ <h4 class="title">
});

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);

Expand All @@ -400,7 +466,15 @@ <h4 class="title">
// 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;
}

Expand Down
109 changes: 106 additions & 3 deletions packages/sdk/src/document/crdt/rga_tree_split.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,14 +588,20 @@ export class RGATreeSplit<T extends RGATreeSplitValue> implements GCParent {
* @param range - range of RGATreeSplitNode
* @param editedAt - edited time
* @param value - value
* @returns `[RGATreeSplitPos, Array<GCPair>, Array<Change>]`
* @returns `[RGATreeSplitPos, Array<GCPair>, DataSize, Array<ValueChange<T>>, Array<T>]`
*/
public edit(
range: RGATreeSplitPosRange,
editedAt: TimeTicket,
value?: T,
versionVector?: VersionVector,
): [RGATreeSplitPos, Array<GCPair>, DataSize, Array<ValueChange<T>>] {
): [
RGATreeSplitPos,
Array<GCPair>,
DataSize,
Array<ValueChange<T>>,
Array<T>,
] {
const diff = { data: 0, meta: 0 };

// 01. split nodes with from and to
Expand Down Expand Up @@ -650,11 +656,13 @@ export class RGATreeSplit<T extends RGATreeSplitValue> implements GCParent {

// 04. add removed node
const pairs: Array<GCPair> = [];
const removedValues: Array<T> = [];
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];
}

/**
Expand Down Expand Up @@ -723,6 +731,101 @@ export class RGATreeSplit<T extends RGATreeSplitValue> 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.
*
* - 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<T> | 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.
*/
Expand Down
Loading