UNPKG

open-collaboration-yjs

Version:

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

316 lines 11.4 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. // ****************************************************************************** export var YTextChange; (function (YTextChange) { function sort(changes) { // 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); } YTextChange.sort = sort; })(YTextChange || (YTextChange = {})); export var YTextChangeDelta; (function (YTextChangeDelta) { function isInsert(delta) { return typeof delta.insert === 'string'; } YTextChangeDelta.isInsert = isInsert; function isDelete(delta) { return typeof delta.delete === 'number'; } YTextChangeDelta.isDelete = isDelete; function isRetain(delta) { return typeof delta.retain === 'number'; } YTextChangeDelta.isRetain = isRetain; function toChanges(delta) { const changes = []; 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; } YTextChangeDelta.toChanges = toChanges; })(YTextChangeDelta || (YTextChangeDelta = {})); ; export class YjsNormalizedTextDocument { _yjsText; _text; _textLength; _normalizedLength; _changeSets = []; _offsets; observer; constructor(yjsText, callback) { this._yjsText = yjsText; this._text = yjsText.toString(); this.observer = event => { this.observe(event, callback); }; yjsText.observe(this.observer); } async observe(event, callback) { if (event.transaction.local) { return; } const hasCR = this._text.includes('\r'); const changes = YTextChangeDelta.toChanges(event.delta); const changeSet = []; 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 = { before, after, }; this._changeSets.push(changeSetItem); await callback(changeSet); const index = this._changeSets.indexOf(changeSetItem); if (index !== -1) { this._changeSets.splice(index, 1); } } dispose() { this._yjsText.unobserve(this.observer); } originalOffset(normalizedOffset) { const lineOffset = this.findLineOffset(normalizedOffset, 'normalized').offsets; const delta = normalizedOffset - lineOffset.normalized; const originalOffset = lineOffset.offset + delta; return originalOffset; } originalOffsetAt(position) { return this.offsetAt(position, 'offset', this._textLength); } offsetAt(position, key, max) { 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) { return this.positionAt(this.originalOffset(normalizedOffset)); } positionAt(offset) { 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) { const lineOffset = this.findLineOffset(offset, 'offset').offsets; const delta = offset - lineOffset.offset; const normalizedOffset = lineOffset.normalized + delta; return normalizedOffset; } normalizedOffsetAt(position) { return this.offsetAt(position, 'normalized', this._normalizedLength); } update(event) { 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(); } } shouldApply(changes) { 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; } doUpdate(changes, yjs) { // 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. */ countNormalizedOffsets(start, end) { let nStart = 0; let nEnd = 0; let i = 0; for (; i < end; i++) { if (this._text.charCodeAt(i) !== 13 /* CharCode.CarriageReturn */) { if (i < start) { nStart++; } nEnd++; } } return [nStart, nEnd]; } normalize(text, withCR) { const nl = withCR ? '\r\n' : '\n'; return text.replace(/\r?\n/g, nl); } ensureBeforeEOL(offset, lineOffset) { while (offset > lineOffset && isEOL(this._text.charCodeAt(offset - 1))) { offset--; } return offset; } findLineOffset(offset, key) { 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 }; } getLineOffsets() { if (this._offsets === undefined) { const lineOffsets = computeNormalizedLineOffsets(this._text); this._offsets = lineOffsets.offsets; this._textLength = lineOffsets.length; this._normalizedLength = lineOffsets.normalizedLength; } return this._offsets; } } function computeNormalizedLineOffsets(text) { const result = [{ 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 === 13 /* CharCode.CarriageReturn */ && i + 1 < text.length && text.charCodeAt(i + 1) === 10 /* 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) { return char === 13 /* CharCode.CarriageReturn */ || char === 10 /* CharCode.LineFeed */; } //# sourceMappingURL=yjs-normalized-text.js.map