fix in db and collaborate
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
|
||||
|
||||
function utf16Length(value) {
|
||||
return String(value || "").length;
|
||||
}
|
||||
|
||||
function normalizeOwnerSpans(spans, length) {
|
||||
const result = [];
|
||||
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
|
||||
for (const source of sorted) {
|
||||
const start = Math.max(0, Math.min(length, Number(source?.start) || 0));
|
||||
const end = Math.max(start, Math.min(length, Number(source?.end) || 0));
|
||||
const owner = String(source?.owner || "");
|
||||
if (!owner || end <= start) continue;
|
||||
const previous = result.at(-1);
|
||||
if (previous && previous.owner === owner && start <= previous.end) {
|
||||
previous.end = Math.max(previous.end, end);
|
||||
continue;
|
||||
}
|
||||
const clippedStart = previous && start < previous.end ? previous.end : start;
|
||||
if (end > clippedStart) result.push({ start: clippedStart, end, owner });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sliceOwnerSpans(spans, start, length) {
|
||||
const end = start + length;
|
||||
return normalizeOwnerSpans((spans || []).flatMap(span => {
|
||||
const overlapStart = Math.max(start, span.start);
|
||||
const overlapEnd = Math.min(end, span.end);
|
||||
return overlapEnd > overlapStart
|
||||
? [{ start: overlapStart - start, end: overlapEnd - start, owner: span.owner }]
|
||||
: [];
|
||||
}), length);
|
||||
}
|
||||
|
||||
function shiftOwnerSpans(spans, offset) {
|
||||
return (spans || []).map(span => ({ start: span.start + offset, end: span.end + offset, owner: span.owner }));
|
||||
}
|
||||
|
||||
function appendComponent(components, component) {
|
||||
if (!component) return;
|
||||
if (component.kind === "retain" || component.kind === "delete") {
|
||||
const count = Number(component.count) || 0;
|
||||
if (count <= 0) return;
|
||||
const previous = components.at(-1);
|
||||
if (previous?.kind === component.kind) previous.count += count;
|
||||
else components.push({ kind: component.kind, count });
|
||||
return;
|
||||
}
|
||||
if (component.kind !== "insert") throw new Error("Unknown operation component");
|
||||
const text = String(component.text || "");
|
||||
if (!text) return;
|
||||
const owners = normalizeOwnerSpans(component.owners, text.length);
|
||||
const previous = components.at(-1);
|
||||
if (previous?.kind === "insert") {
|
||||
const offset = previous.text.length;
|
||||
previous.text += text;
|
||||
previous.owners = normalizeOwnerSpans([
|
||||
...(previous.owners || []),
|
||||
...shiftOwnerSpans(owners, offset),
|
||||
], previous.text.length);
|
||||
} else components.push({ kind: "insert", text, owners });
|
||||
}
|
||||
|
||||
export function normalizeOperation(operation) {
|
||||
const components = [];
|
||||
for (const component of operation?.components || []) appendComponent(components, component);
|
||||
return { components };
|
||||
}
|
||||
|
||||
export function operationBaseLength(operation) {
|
||||
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||
length + (component.kind === "retain" || component.kind === "delete" ? component.count : 0), 0);
|
||||
}
|
||||
|
||||
export function operationTargetLength(operation) {
|
||||
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||
length + (component.kind === "retain" ? component.count : component.kind === "insert" ? component.text.length : 0), 0);
|
||||
}
|
||||
|
||||
export function identityOperation(length) {
|
||||
return normalizeOperation({ components: length > 0 ? [{ kind: "retain", count: length }] : [] });
|
||||
}
|
||||
|
||||
function isUtf16Boundary(value, offset) {
|
||||
if (offset <= 0 || offset >= value.length) return true;
|
||||
const previous = value.charCodeAt(offset - 1);
|
||||
const next = value.charCodeAt(offset);
|
||||
return !(previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff);
|
||||
}
|
||||
|
||||
export function operationFromEdit(previousText, nextText, nextAuthorship) {
|
||||
previousText = String(previousText || "");
|
||||
nextText = String(nextText || "");
|
||||
let prefix = 0;
|
||||
const shared = Math.min(previousText.length, nextText.length);
|
||||
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
|
||||
while (prefix > 0 && (!isUtf16Boundary(previousText, prefix) || !isUtf16Boundary(nextText, prefix))) prefix--;
|
||||
|
||||
let oldSuffix = previousText.length;
|
||||
let newSuffix = nextText.length;
|
||||
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
|
||||
oldSuffix--;
|
||||
newSuffix--;
|
||||
}
|
||||
while (!isUtf16Boundary(previousText, oldSuffix) || !isUtf16Boundary(nextText, newSuffix)) {
|
||||
oldSuffix++;
|
||||
newSuffix++;
|
||||
}
|
||||
|
||||
const components = [];
|
||||
appendComponent(components, { kind: "retain", count: prefix });
|
||||
appendComponent(components, { kind: "delete", count: oldSuffix - prefix });
|
||||
const insertedText = nextText.slice(prefix, newSuffix);
|
||||
appendComponent(components, {
|
||||
kind: "insert",
|
||||
text: insertedText,
|
||||
owners: sliceOwnerSpans(nextAuthorship?.spans || [], prefix, insertedText.length),
|
||||
});
|
||||
appendComponent(components, { kind: "retain", count: previousText.length - oldSuffix });
|
||||
return { components };
|
||||
}
|
||||
|
||||
class OperationCursor {
|
||||
constructor(operation) {
|
||||
this.components = normalizeOperation(operation).components;
|
||||
this.index = 0;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
get current() { return this.components[this.index] || null; }
|
||||
get kind() { return this.current?.kind || null; }
|
||||
get remaining() {
|
||||
const component = this.current;
|
||||
if (!component) return 0;
|
||||
return (component.kind === "insert" ? component.text.length : component.count) - this.offset;
|
||||
}
|
||||
|
||||
take(count = this.remaining) {
|
||||
const component = this.current;
|
||||
if (!component || count <= 0 || count > this.remaining) throw new Error("Invalid operation cursor read");
|
||||
let part;
|
||||
if (component.kind === "insert") {
|
||||
part = {
|
||||
kind: "insert",
|
||||
text: component.text.slice(this.offset, this.offset + count),
|
||||
owners: sliceOwnerSpans(component.owners, this.offset, count),
|
||||
};
|
||||
} else part = { kind: component.kind, count };
|
||||
this.offset += count;
|
||||
if (this.offset === (component.kind === "insert" ? component.text.length : component.count)) {
|
||||
this.index++;
|
||||
this.offset = 0;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
export function composeOperations(first, second) {
|
||||
first = normalizeOperation(first);
|
||||
second = normalizeOperation(second);
|
||||
if (operationTargetLength(first) !== operationBaseLength(second)) throw new Error("Cannot compose operations with different lengths");
|
||||
const left = new OperationCursor(first);
|
||||
const right = new OperationCursor(second);
|
||||
const components = [];
|
||||
|
||||
while (left.current || right.current) {
|
||||
if (right.kind === "insert") {
|
||||
appendComponent(components, right.take());
|
||||
continue;
|
||||
}
|
||||
if (left.kind === "delete") {
|
||||
appendComponent(components, left.take());
|
||||
continue;
|
||||
}
|
||||
if (!left.current || !right.current) throw new Error("Incomplete operation composition");
|
||||
const count = Math.min(left.remaining, right.remaining);
|
||||
const leftKind = left.kind;
|
||||
const rightKind = right.kind;
|
||||
if (leftKind === "retain" && rightKind === "retain") {
|
||||
appendComponent(components, { kind: "retain", count });
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else if (leftKind === "retain" && rightKind === "delete") {
|
||||
appendComponent(components, { kind: "delete", count });
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else if (leftKind === "insert" && rightKind === "retain") {
|
||||
appendComponent(components, left.take(count));
|
||||
right.take(count);
|
||||
} else if (leftKind === "insert" && rightKind === "delete") {
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else throw new Error("Unsupported operation composition");
|
||||
}
|
||||
return { components };
|
||||
}
|
||||
|
||||
export function transformOperations(leftOperation, rightOperation, leftBeforeRight = true) {
|
||||
leftOperation = normalizeOperation(leftOperation);
|
||||
rightOperation = normalizeOperation(rightOperation);
|
||||
if (operationBaseLength(leftOperation) !== operationBaseLength(rightOperation)) throw new Error("Cannot transform operations with different base lengths");
|
||||
const left = new OperationCursor(leftOperation);
|
||||
const right = new OperationCursor(rightOperation);
|
||||
const leftPrime = [];
|
||||
const rightPrime = [];
|
||||
|
||||
while (left.current || right.current) {
|
||||
if (left.kind === "insert" && (right.kind !== "insert" || leftBeforeRight)) {
|
||||
const part = left.take();
|
||||
appendComponent(leftPrime, part);
|
||||
appendComponent(rightPrime, { kind: "retain", count: part.text.length });
|
||||
continue;
|
||||
}
|
||||
if (right.kind === "insert") {
|
||||
const part = right.take();
|
||||
appendComponent(leftPrime, { kind: "retain", count: part.text.length });
|
||||
appendComponent(rightPrime, part);
|
||||
continue;
|
||||
}
|
||||
if (!left.current || !right.current) throw new Error("Incomplete operation transform");
|
||||
const count = Math.min(left.remaining, right.remaining);
|
||||
if (left.kind === "retain" && right.kind === "retain") {
|
||||
appendComponent(leftPrime, { kind: "retain", count });
|
||||
appendComponent(rightPrime, { kind: "retain", count });
|
||||
} else if (left.kind === "delete" && right.kind === "retain") {
|
||||
appendComponent(leftPrime, { kind: "delete", count });
|
||||
} else if (left.kind === "retain" && right.kind === "delete") {
|
||||
appendComponent(rightPrime, { kind: "delete", count });
|
||||
} else if (left.kind !== "delete" || right.kind !== "delete") throw new Error("Unsupported operation transform");
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
}
|
||||
return [{ components: leftPrime }, { components: rightPrime }];
|
||||
}
|
||||
|
||||
export function applyOperation(text, operation) {
|
||||
text = String(text || "");
|
||||
operation = normalizeOperation(operation);
|
||||
if (operationBaseLength(operation) !== text.length) throw new Error("Operation base length does not match document");
|
||||
let cursor = 0;
|
||||
let result = "";
|
||||
for (const component of operation.components) {
|
||||
if (component.kind === "retain") {
|
||||
result += text.slice(cursor, cursor + component.count);
|
||||
cursor += component.count;
|
||||
} else if (component.kind === "delete") cursor += component.count;
|
||||
else result += component.text;
|
||||
}
|
||||
if (cursor !== text.length) throw new Error("Operation did not consume the whole document");
|
||||
return result;
|
||||
}
|
||||
|
||||
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
|
||||
const sourceEnd = sourceStart + length;
|
||||
for (const span of spans || []) {
|
||||
const start = Math.max(sourceStart, span.start);
|
||||
const end = Math.min(sourceEnd, span.end);
|
||||
if (end > start) target.push({ start: outputStart + start - sourceStart, end: outputStart + end - sourceStart, owner: span.owner });
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOperationToDocument(content, ownerMap, operation, ownerReplacements = []) {
|
||||
content = String(content || "");
|
||||
operation = normalizeOperation(operation);
|
||||
if (operationBaseLength(operation) !== content.length) throw new Error("Operation base length does not match document");
|
||||
const sourceModel = parseAuthorship(content, ownerMap);
|
||||
const outputSpans = [];
|
||||
let sourceOffset = 0;
|
||||
let outputOffset = 0;
|
||||
let nextContent = "";
|
||||
for (const component of operation.components) {
|
||||
if (component.kind === "retain") {
|
||||
nextContent += content.slice(sourceOffset, sourceOffset + component.count);
|
||||
copyRetainedSpans(outputSpans, sourceModel.spans, sourceOffset, component.count, outputOffset);
|
||||
sourceOffset += component.count;
|
||||
outputOffset += component.count;
|
||||
} else if (component.kind === "delete") sourceOffset += component.count;
|
||||
else {
|
||||
nextContent += component.text;
|
||||
outputSpans.push(...shiftOwnerSpans(component.owners, outputOffset));
|
||||
outputOffset += component.text.length;
|
||||
}
|
||||
}
|
||||
if (sourceOffset !== content.length) throw new Error("Operation did not consume the whole document");
|
||||
const replacementMap = new Map((ownerReplacements || []).map(item => [String(item?.owner || ""), String(item?.replacement || "")]));
|
||||
const replacedSpans = outputSpans.map(span => {
|
||||
const identity = String(span.owner || "").split("\u001f", 1)[0];
|
||||
const replacement = replacementMap.get(identity);
|
||||
return replacement ? { ...span, owner: replacement } : span;
|
||||
});
|
||||
const model = { version: 2, spans: normalizeOwnerSpans(replacedSpans, nextContent.length) };
|
||||
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
|
||||
}
|
||||
|
||||
export function operationEquals(left, right) {
|
||||
return JSON.stringify(normalizeOperation(left)) === JSON.stringify(normalizeOperation(right));
|
||||
}
|
||||
|
||||
export function compareOperationKeys(left, right) {
|
||||
const leftClient = String(left?.clientId || left?.client_id || "");
|
||||
const rightClient = String(right?.clientId || right?.client_id || "");
|
||||
if (leftClient !== rightClient) return leftClient < rightClient ? -1 : 1;
|
||||
const leftUpdate = Number(left?.updateId ?? left?.update_id ?? 0);
|
||||
const rightUpdate = Number(right?.updateId ?? right?.update_id ?? 0);
|
||||
return leftUpdate === rightUpdate ? 0 : leftUpdate < rightUpdate ? -1 : 1;
|
||||
}
|
||||
|
||||
export function documentAfterPending(serverContent, serverOwnerMap, outstanding, buffer) {
|
||||
let documentState = { content: serverContent, ownerMap: serverOwnerMap };
|
||||
for (const pending of [outstanding, buffer]) {
|
||||
if (!pending) continue;
|
||||
documentState = applyOperationToDocument(
|
||||
documentState.content,
|
||||
documentState.ownerMap,
|
||||
pending.operation,
|
||||
pending.ownerReplacements,
|
||||
);
|
||||
}
|
||||
return documentState;
|
||||
}
|
||||
Reference in New Issue
Block a user