codemirror-ot
Version:
Operational Transformation adapter for CodeMirror 6.
169 lines (153 loc) • 5.71 kB
JavaScript
import { ViewPlugin } from '@codemirror/view';
import { changesToOpJSON1, opToChangesJSON1 } from './translation';
import { canOpAffectPath } from './canOpAffectPath';
// Inspired by:
// https://github.com/codemirror/collab/blob/main/src/collab.ts
// https://codemirror.net/6/examples/collab/
// https://github.com/yjs/y-codemirror.next/blob/main/src/y-sync.js#L107
// https://github.com/vizhub-core/vizhub/blob/main/vizhub-v2/packages/neoFrontend/src/pages/VizPage/Body/Editor/CodeEditor/CodeArea/CodeAreaCodeMirror5/index.js
export const json1Sync = ({
shareDBDoc,
path = [],
json1,
textUnicode,
debug = false,
}) =>
ViewPlugin.fromClass(
class {
// ShareDB --> CodeMirror
constructor(view) {
this.view = view;
this.submittedOps = new Set();
this.handleOp = (op) => {
// Do not process ops if the lock is set.
if (this.lock) return;
// Check if this is our own op that we submitted
const opKey = JSON.stringify(op);
if (this.submittedOps.has(opKey)) {
this.submittedOps.delete(opKey);
if (debug) {
console.log('Ignoring own op echoed back from ShareDB');
}
return;
}
if (debug) {
console.log('V+WTF');
console.log(
'Received raw op from ShareDB: \n' + JSON.stringify(op, null, 2),
);
}
// Ignore ops that are not arrays
if (!Array.isArray(op)) return;
this.lock = true;
// Handle single-part and multi-part ops.
// Example potential values for `op`:
// - Single-part case (most common):
// ["files","73869312","text",{"es":[521," "]}
// - Multi-part case:
// [["files","73869312","text",{"es":[521," "]}],["isInteracting",{"r":true}]]
// - Special multi-file case from vizhub-fs:
// ["files",["22133515","text",{"es":[304,"\n"]}],["35721964","text",{...}]]
const opComponents = Array.isArray(op[0]) ? op : [op];
for (let opComponent of opComponents) {
if (debug) {
console.log(
'Examining op component: \n' +
JSON.stringify(opComponent, null, 2),
);
}
if (debug) {
console.log(
'canOpAffectPath(opComponent, path): ' +
canOpAffectPath(opComponent, path),
);
console.log('path: ' + JSON.stringify(path));
}
// Ignore ops fired as a result of a change from `update` (this.lock).
// Ignore ops that have different, irrelevant, paths (canOpAffectPath).
if (canOpAffectPath(opComponent, path)) {
// Use current editor content for position calculations
const currentEditorContent = view.state.doc.sliceString(0);
if (debug) {
console.log('Received op from ShareDB');
console.log(' op: ' + JSON.stringify(opComponent, null, 2));
console.log(
' current editor content: ' +
JSON.stringify(currentEditorContent),
);
console.log(
' current editor length: ' + currentEditorContent.length,
);
console.log(
' generated changes: ' +
JSON.stringify(
opToChangesJSON1(opComponent, path, currentEditorContent),
null,
2,
),
);
}
view.dispatch({
changes: opToChangesJSON1(
opComponent,
path,
currentEditorContent,
),
});
}
}
this.lock = false;
};
shareDBDoc.on('op', this.handleOp);
}
// CodeMirror --> ShareDB
update(update) {
// Ignore updates fired as a result of an op from `handleOp` (this.lock).
// Ignore updates that do not change the doc (update.docChanged).
if (!this.lock && update.docChanged) {
this.lock = true;
const op = changesToOpJSON1(
path,
update.changes,
update.startState.doc,
json1,
textUnicode,
);
if (debug) {
console.log('Received change from CodeMirror');
console.log(
' changes:' + JSON.stringify(update.changes.toJSON(), null, 2),
);
console.log(' iterChanges:');
update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
console.log(
' ' +
JSON.stringify({
fromA,
toA,
fromB,
toB,
inserted: inserted.sliceString(0, inserted.length, '\n'),
}),
);
});
console.log(' generated json1 op: ' + JSON.stringify(op, null, 2));
}
// Track this op so we can ignore it when it echoes back
if (op) {
const opKey = JSON.stringify(op);
this.submittedOps.add(opKey);
if (debug) {
console.log('Tracking submitted op: ' + opKey);
}
}
shareDBDoc.submitOp(op);
this.lock = false;
}
}
// Remove ShareDB listener.
destroy() {
shareDBDoc.off('op', this.handleOp);
}
},
);