UNPKG

open-collaboration-yjs

Version:

Open Collaboration Yjs integration, part of the Open Collaboration Tools project

378 lines (340 loc) 13.3 kB
// ****************************************************************************** // Copyright 2025 TypeFox GmbH // This program and the accompanying materials are made available under the // terms of the MIT License, which is available in the project root. // ****************************************************************************** import * as Y from 'yjs'; import * as types from 'open-collaboration-protocol'; export interface YTextChange { start: number; end: number; text: string; } export namespace YTextChange { export function sort(changes: YTextChange[]): YTextChange[] { // Changes need to be sorted by start position // Most algorithms that use this data expect that the changes are sorted by ascending start position return [...changes].sort((a, b) => a.start - b.start); } } export interface YTextChangeDelta { insert?: string | object | Y.AbstractType<any>; delete?: number; retain?: number; } export namespace YTextChangeDelta { export function isInsert(delta: YTextChangeDelta): delta is { insert: string } { return typeof delta.insert === 'string'; } export function isDelete(delta: YTextChangeDelta): delta is { delete: number } { return typeof delta.delete === 'number'; } export function isRetain(delta: YTextChangeDelta): delta is { retain: number } { return typeof delta.retain === 'number'; } export function toChanges(delta: YTextChangeDelta[]): YTextChange[] { const changes: YTextChange[] = []; let index = 0; for (const op of delta) { if (isRetain(op)) { index += op.retain; } else if (isInsert(op)) { changes.push({ start: index, end: index, text: op.insert }); } else if (isDelete(op)) { changes.push({ start: index, end: index + op.delete, text: '' }); // Increase the index by the number of characters deleted // In the client, every following operation will still operate on the "old code" // So we need to adjust the index to reflect that index += op.delete; } } return changes; } } export interface YTextChangeEvent { changes: YTextChange[] | string; }; export type YjsTextDocumentChangedCallback = (changes: YTextChange[]) => Promise<void>; interface ChangeSet { before: string; after: string; } export class YjsNormalizedTextDocument implements types.Disposable { private _yjsText: Y.Text; private _text: string; private _textLength: number; private _normalizedLength: number; private _changeSets: ChangeSet[] = []; private _offsets: NormalizedLineOffset[] | undefined; private observer: Parameters<Y.Text['observe']>[0]; constructor(yjsText: Y.Text, callback: YjsTextDocumentChangedCallback) { this._yjsText = yjsText; this._text = yjsText.toString(); this.observer = event => { this.observe(event, callback); }; yjsText.observe(this.observer); } private async observe(event: Y.YTextEvent, callback: YjsTextDocumentChangedCallback): Promise<void> { if (event.transaction.local) { return; } const hasCR = this._text.includes('\r'); const changes = YTextChangeDelta.toChanges(event.delta); const changeSet: YTextChange[] = []; for (const change of changes) { changeSet.push({ start: this.originalOffset(change.start), end: this.originalOffset(change.end), text: this.normalize(change.text, hasCR), }); } const before = this._text; // Update the internal text string, but don't broadcast the changes this.doUpdate({ changes: changeSet }, false); const after = this._text; const changeSetItem: ChangeSet = { before, after, }; this._changeSets.push(changeSetItem); await callback(changeSet); const index = this._changeSets.indexOf(changeSetItem); if (index !== -1) { this._changeSets.splice(index, 1); } } dispose(): void { this._yjsText.unobserve(this.observer); } originalOffset(normalizedOffset: number): number { const lineOffset = this.findLineOffset(normalizedOffset, 'normalized').offsets; const delta = normalizedOffset - lineOffset.normalized; const originalOffset = lineOffset.offset + delta; return originalOffset; } originalOffsetAt(position: types.Position): number { return this.offsetAt(position, 'offset', this._textLength); } private offsetAt(position: types.Position, key: keyof NormalizedLineOffset, max: number): number { const lineOffsets = this.getLineOffsets(); if (position.line >= lineOffsets.length) { return max; } else if (position.line < 0) { return 0; } const lineOffset = lineOffsets[position.line][key]; if (position.character <= 0) { return lineOffset; } return Math.min(lineOffset + position.character, max); } positionAtNormalized(normalizedOffset: number): types.Position { return this.positionAt(this.originalOffset(normalizedOffset)); } positionAt(offset: number): types.Position { const lineOffsets = this.getLineOffsets(); let low = 0, high = lineOffsets.length; if (high === 0) { return { line: 0, character: offset }; } while (low < high) { const mid = Math.floor((low + high) / 2); if (lineOffsets[mid].offset > offset) { high = mid; } else { low = mid + 1; } } // low is the least x for which the line offset is larger than the current offset // or array.length if no line offset is larger than the current offset const line = low - 1; offset = this.ensureBeforeEOL(offset, lineOffsets[line].offset); return { line, character: offset - lineOffsets[line].offset }; } normalizedOffset(offset: number): number { const lineOffset = this.findLineOffset(offset, 'offset').offsets; const delta = offset - lineOffset.offset; const normalizedOffset = lineOffset.normalized + delta; return normalizedOffset; } normalizedOffsetAt(position: types.Position): number { return this.offsetAt(position, 'normalized', this._normalizedLength); } update(event: YTextChangeEvent): void { const run = () => { if (typeof event.changes === 'string') { this.doUpdate(event, true); } else { if (this.shouldApply(event.changes)) { this.doUpdate(event, true); } } }; if (this._yjsText.doc) { // Wrap the update in a single Yjs transaction this._yjsText.doc.transact(() => run()); } else { run(); } } private shouldApply(changes: YTextChange[]): boolean { changes = YTextChange.sort(changes); for (const changeSet of this._changeSets) { let fullText = changeSet.before; let delta = 0; for (const change of changes) { const { start, end, text } = change; fullText = fullText.substring(0, start + delta) + text + fullText.substring(end + delta); delta += change.text.length - (end - start); } if (fullText === changeSet.after) { return false; } } return true; } private doUpdate(changes: YTextChangeEvent, yjs: boolean): void { // Offsets are always reset, they will be recomputed on the next call to getLineOffsets this._offsets = undefined; if (typeof changes.changes === 'string') { this._text = changes.changes; if (yjs) { const yjsText = this._yjsText.toString(); this._yjsText.delete(0, yjsText.length); this._yjsText.insert(0, this.normalize(this._text, false)); } } else { let delta = 0; for (const change of YTextChange.sort(changes.changes)) { const startOffset = change.start + delta; const endOffset = change.end + delta; const [normalizedStart, normalizedEnd] = this.countNormalizedOffsets(startOffset, endOffset); this._text = this._text.substring(0, startOffset) + change.text + this._text.substring(endOffset); delta += change.text.length - (endOffset - startOffset); if (yjs) { this._yjsText.delete(normalizedStart, normalizedEnd - normalizedStart); this._yjsText.insert(normalizedStart, this.normalize(change.text, false)); } } } } /** * If we are within an update, offsets are unavailable. * Therefore, we need a secondary method to count the normalized offsets up to a certain offset in the text. * * Note that this method is not very efficient, as it needs to iterate over the entire text. * However, in 99% of use cases, it is only called once. Making it fast enough for the common case. */ private countNormalizedOffsets(start: number, end: number): [number, number] { let nStart = 0; let nEnd = 0; let i = 0; for (;i < end; i++) { if (this._text.charCodeAt(i) !== CharCode.CarriageReturn) { if (i < start) { nStart++; } nEnd++; } } return [nStart, nEnd]; } private normalize(text: string, withCR: boolean): string { const nl = withCR ? '\r\n' : '\n'; return text.replace(/\r?\n/g, nl); } private ensureBeforeEOL(offset: number, lineOffset: number): number { while (offset > lineOffset && isEOL(this._text.charCodeAt(offset - 1))) { offset--; } return offset; } private findLineOffset(offset: number, key: keyof NormalizedLineOffset): { offsets: NormalizedLineOffset; index: number; } { const lineOffsets = this.getLineOffsets(); let low = 0, high = lineOffsets.length; while (low < high) { // eslint-disable-next-line no-bitwise const mid = ((low + high) / 2) | 0; if (lineOffsets[mid][key] > offset) { high = mid; } else { low = mid + 1; } } // low is the least x for which the line offset is larger than the current offset const line = low - 1; return { offsets: lineOffsets[line], index: line }; } private getLineOffsets(): NormalizedLineOffset[] { if (this._offsets === undefined) { const lineOffsets = computeNormalizedLineOffsets(this._text); this._offsets = lineOffsets.offsets; this._textLength = lineOffsets.length; this._normalizedLength = lineOffsets.normalizedLength; } return this._offsets; } } const enum CharCode { /** * The `\n` character. */ LineFeed = 10, /** * The `\r` character. */ CarriageReturn = 13, } interface NormalizedLineOffset { normalized: number; offset: number; } function computeNormalizedLineOffsets(text: string): { offsets: NormalizedLineOffset[]; length: number; normalizedLength: number; } { const result: NormalizedLineOffset[] = [{ normalized: 0, offset: 0, }]; let normalizationOffset = 0; for (let i = 0; i < text.length; i++) { const ch = text.charCodeAt(i); if (isEOL(ch)) { if (ch === CharCode.CarriageReturn && i + 1 < text.length && text.charCodeAt(i + 1) === CharCode.LineFeed) { i++; normalizationOffset++; } const offset = i + 1; const normalizedOffset = offset - normalizationOffset; result.push({ normalized: normalizedOffset, offset: offset, }); } } return { offsets: result, length: text.length, normalizedLength: text.length + result.length - normalizationOffset, }; } function isEOL(char: number) { return char === CharCode.CarriageReturn || char === CharCode.LineFeed; }