open-collaboration-yjs
Version:
Open Collaboration Yjs integration, part of the Open Collaboration Tools project
316 lines • 11.4 kB
JavaScript
// ******************************************************************************
// 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