UNPKG

codemirror-ot

Version:

Operational Transformation adapter for CodeMirror 6.

266 lines (229 loc) 8.46 kB
// This module is able to translate from CodeMirror ChangeSet to OT ops // and back, for both json0 and json1 OT types. // // Inspired by https://github.com/yjs/y-codemirror.next/blob/main/src/y-sync.js // Convert UTF-16 position (used by CodeMirror) to Unicode code point position (used by text-unicode) const utf16ToCodePoint = (str, utf16Pos) => { let codePointPos = 0; let utf16Index = 0; while (utf16Index < utf16Pos && utf16Index < str.length) { const codePoint = str.codePointAt(utf16Index); if (codePoint > 0xffff) { utf16Index += 2; // Surrogate pair takes 2 UTF-16 code units } else { utf16Index += 1; } codePointPos++; } return codePointPos; }; // Convert Unicode code point position (used by text-unicode) to UTF-16 position (used by CodeMirror) const codePointToUtf16 = (str, codePointPos) => { let utf16Index = 0; let currentCodePointPos = 0; while (currentCodePointPos < codePointPos && utf16Index < str.length) { const codePoint = str.codePointAt(utf16Index); if (codePoint > 0xffff) { utf16Index += 2; // Surrogate pair takes 2 UTF-16 code units } else { utf16Index += 1; } currentCodePointPos++; } return utf16Index; }; // Converts a CodeMirror ChangeSet to a json0 OT op. export const changesToOpJSON0 = (path, changeSet, doc) => { const op = []; let offset = 0; // Used to track the position offset based on previous operations changeSet.iterChanges((fromA, toA, fromB, toB, inserted) => { const deletedText = doc.sliceString(fromA, toA, '\n'); const insertedText = inserted.sliceString(0, inserted.length, '\n'); const p = path.concat([fromA + offset]); if (deletedText) { op.push({ p, sd: deletedText }); } if (insertedText) { op.push({ p, si: insertedText }); } offset += insertedText.length; offset -= deletedText.length; }); return op; }; // Converts a CodeMirror ChangeSet to a json1 OT op. // Iterate over all changes in the ChangeSet. // See https://codemirror.net/docs/ref/#state.ChangeSet.iterChanges // See https://codemirror.net/docs/ref/#state.Text.sliceString // This was also the approach taken in the YJS CodeMirror integration. // See https://github.com/yjs/y-codemirror.next/blob/main/src/y-sync.js#L141 export const changesToOpJSON1 = (path, changeSet, doc, json1, textUnicode) => { let op = []; let offset = 0; const fullDoc = doc.sliceString(0, doc.length, '\n'); changeSet.iterChanges((fromA, toA, fromB, toB, inserted) => { const deletedText = doc.sliceString(fromA, toA, '\n'); const insertedText = inserted.sliceString(0, inserted.length, '\n'); // Convert UTF-16 position (CodeMirror) to code point position (text-unicode) const codePointPos = utf16ToCodePoint(fullDoc, fromA) + offset; if (deletedText) { op.push(textUnicode.remove(codePointPos, deletedText)); } if (insertedText) { op.push(textUnicode.insert(codePointPos, insertedText)); } // Update offset in code point space offset += insertedText.length - deletedText.length; }); // Composes string deletion followed by string insertion // to produce a "new kind" of op component that represents // a string replacement (using only a single op component). if (op.length === 0) { return null; } op = op.reduce(textUnicode.type.compose); return json1.editOp(path, 'text-unicode', op); }; export const opToChangesJSON0 = (op) => { const changes = []; let offset = 0; // Used to track the position offset based on previous operations for (let i = 0; i < op.length; i++) { const component = op[i]; const originalPosition = component.p[component.p.length - 1]; const adjustedPosition = originalPosition + offset; // String insert if (component.si !== undefined) { // String replacement if ( i > 0 && op[i - 1].sd !== undefined && JSON.stringify(op[i - 1].p) === JSON.stringify(component.p) ) { // Modify the previous change to be a replacement instead of an insertion if (changes[i - 1]) { changes[i - 1].insert = component.si; } // Undo the offset added by the previous change offset -= op[i - 1].sd.length; // Adjust the offset based on the length of the inserted string // offset += component.si.length; } else { changes.push({ from: adjustedPosition, to: adjustedPosition, insert: component.si, }); // offset += component.si.length; // Adjust offset for inserted string } } // String deletion (not part of a replacement) if ( component.sd !== undefined && (i === 0 || JSON.stringify(op[i - 1].p) !== JSON.stringify(component.p)) ) { changes.push({ from: adjustedPosition, to: adjustedPosition + component.sd.length, }); offset += component.sd.length; // Adjust offset for deleted string } } return changes; }; // Converts a json1 OT op to a CodeMirror ChangeSet. export const opToChangesJSON1 = (op, originalDoc = null) => { if (!op) return []; const changes = []; for (const component of op) { const { es } = component; if (es !== undefined) { let position = 0; for (let i = 0; i < es.length; i++) { const component = es[i]; if (typeof component === 'number') { // It's a skip/retain operation. position += component; } else if (typeof component === 'string') { // Check if the next component is a deletion, indicating a replacement. if ( es[i + 1] && typeof es[i + 1] === 'object' && es[i + 1].d !== undefined ) { let deletedText = typeof es[i + 1].d === 'string' ? es[i + 1].d : ''; let utf16From, utf16To; if (originalDoc) { // Convert from Unicode code point positions to UTF-16 positions using original document utf16From = codePointToUtf16(originalDoc, position); utf16To = codePointToUtf16( originalDoc, position + deletedText.length, ); } else { // Fallback: assume positions are the same (ASCII-only content) utf16From = position; utf16To = position + deletedText.length; } changes.push({ from: utf16From, to: utf16To, insert: component, }); position += deletedText.length; i++; // Skip the next component since we've already handled it. } else { // It's a regular insertion. let utf16Position; if (originalDoc) { utf16Position = codePointToUtf16(originalDoc, position); } else { utf16Position = position; } changes.push({ from: utf16Position, to: utf16Position, insert: component, }); } } else if (component && component.d !== undefined) { if (typeof component.d === 'number') { // It's a deletion by count. let utf16From, utf16To; if (originalDoc) { utf16From = codePointToUtf16(originalDoc, position); utf16To = codePointToUtf16(originalDoc, position + component.d); } else { utf16From = position; utf16To = position + component.d; } changes.push({ from: utf16From, to: utf16To, }); position += component.d; } else if (typeof component.d === 'string') { // It's a deletion of a specific string. let utf16From, utf16To; if (originalDoc) { utf16From = codePointToUtf16(originalDoc, position); utf16To = codePointToUtf16( originalDoc, position + component.d.length, ); } else { utf16From = position; utf16To = position + component.d.length; } changes.push({ from: utf16From, to: utf16To, }); position += component.d.length; } } } } } return changes; };