fix in db and collaborate

This commit is contained in:
Mateusz Gruszczyński
2026-08-04 14:22:26 +02:00
parent 1a77ccd1bf
commit 8d58549d11
26 changed files with 3009 additions and 298 deletions
+277
View File
@@ -0,0 +1,277 @@
/*
* 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 {
applyOperationToDocument,
compareOperationKeys,
composeOperations,
documentAfterPending,
identityOperation,
normalizeOperation,
operationBaseLength,
operationFromEdit,
operationTargetLength,
transformOperations,
} from "@rustpad/collaboration";
import { parseAuthorship } from "@rustpad/authorship";
function hasTextEffect(operation) {
return normalizeOperation(operation).components.some(component => component.kind === "insert" || component.kind === "delete");
}
function mergeOwnerReplacements(previous = [], next = []) {
const replacements = new Map();
for (const item of [...previous, ...next]) {
const owner = String(item?.owner || "");
const replacement = String(item?.replacement || "");
if (owner && replacement) replacements.set(owner, { owner, replacement });
}
return [...replacements.values()];
}
function pendingEnvelope(clientId, updateId, operation, ownerReplacements = []) {
return {
clientId,
updateId,
operation: normalizeOperation(operation),
ownerReplacements: mergeOwnerReplacements([], ownerReplacements),
};
}
function serverEnvelope(message) {
return {
baseRevisionId: Number(message?.base_revision_id ?? message?.baseRevisionId),
revisionId: Number(message?.revision_id ?? message?.revisionId),
clientId: String(message?.client_id ?? message?.clientId ?? ""),
updateId: Number(message?.update_id ?? message?.updateId ?? 0),
operation: normalizeOperation(message?.operation),
ownerReplacements: mergeOwnerReplacements([], message?.owner_replacements ?? message?.ownerReplacements ?? []),
};
}
export class CollaborationRevisionGapError extends Error {
constructor(expected, actual) {
super(`Collaborative revision gap: expected ${expected}, received ${actual}`);
this.name = "CollaborationRevisionGapError";
this.expected = expected;
this.actual = actual;
}
}
export class CollaborationSession {
constructor(clientId) {
this.clientId = String(clientId || "");
this.ready = false;
this.serverContent = "";
this.serverOwnerMap = "[]";
this.revisionId = 0;
this.outstanding = null;
this.buffer = null;
this.nextUpdateId = 1;
}
initialize(content, ownerMap, revisionId, { clearPending = true } = {}) {
this.serverContent = String(content || "");
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
this.revisionId = Number(revisionId) || 0;
if (clearPending) {
this.outstanding = null;
this.buffer = null;
}
this.ready = true;
}
localDocument() {
return documentAfterPending(
this.serverContent,
this.serverOwnerMap,
this.outstanding,
this.buffer,
);
}
hasPending() {
return Boolean(this.outstanding || this.buffer);
}
queue(operation, ownerReplacements = []) {
operation = normalizeOperation(operation);
const replacements = mergeOwnerReplacements([], ownerReplacements);
if (!hasTextEffect(operation) && !replacements.length) return null;
const localLength = this.localDocument().content.length;
if (operationBaseLength(operation) !== localLength) {
throw new Error("Local operation base length does not match the collaborative document");
}
if (!this.buffer) {
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
} else {
this.buffer.operation = composeOperations(this.buffer.operation, operation);
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
}
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
throw new Error("Buffered operation target length does not match the collaborative document");
}
return this.buffer;
}
sendable() {
if (!this.ready || this.outstanding || !this.buffer) return null;
return {
baseRevisionId: this.revisionId,
updateId: this.buffer.updateId,
operation: this.buffer.operation,
ownerReplacements: this.buffer.ownerReplacements,
};
}
markSent(updateId) {
if (this.outstanding || !this.buffer || this.buffer.updateId !== Number(updateId)) return false;
this.outstanding = this.buffer;
this.buffer = null;
return true;
}
integrate(message) {
const remote = serverEnvelope(message);
if (!Number.isFinite(remote.baseRevisionId) || !Number.isFinite(remote.revisionId)) {
throw new Error("Collaborative update is missing revision metadata");
}
if (remote.revisionId <= this.revisionId) return { duplicate: true, ownAck: false, remote: false };
if (remote.baseRevisionId !== this.revisionId) {
throw new CollaborationRevisionGapError(this.revisionId, remote.baseRevisionId);
}
const nextServer = applyOperationToDocument(
this.serverContent,
this.serverOwnerMap,
remote.operation,
remote.ownerReplacements,
);
const ownAck = Boolean(
this.outstanding
&& remote.clientId === this.clientId
&& remote.updateId === this.outstanding.updateId
);
if (ownAck) {
this.outstanding = null;
} else {
let remoteForPending = remote.operation;
if (this.outstanding) {
const outstandingBeforeRemote = compareOperationKeys(this.outstanding, remote) < 0;
const [outstandingPrime, remotePrime] = transformOperations(
this.outstanding.operation,
remoteForPending,
outstandingBeforeRemote,
);
this.outstanding.operation = outstandingPrime;
remoteForPending = remotePrime;
}
if (this.buffer) {
const bufferBeforeRemote = compareOperationKeys(this.buffer, remote) < 0;
const [bufferPrime] = transformOperations(
this.buffer.operation,
remoteForPending,
bufferBeforeRemote,
);
this.buffer.operation = bufferPrime;
}
}
this.serverContent = nextServer.content;
this.serverOwnerMap = nextServer.ownerMap;
this.revisionId = remote.revisionId;
return { duplicate: false, ownAck, remote: !ownAck };
}
resynchronize(message) {
const canonicalContent = String(message?.content || "");
const canonicalOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
const canonicalRevisionId = Number(message?.revision_id) || 0;
const outstanding = this.outstanding;
const buffer = this.buffer;
const local = this.localDocument();
const acknowledgedIds = (message?.acknowledged_update_ids || [])
.map(Number)
.filter(Number.isFinite);
const acknowledgedThrough = acknowledgedIds.length ? Math.max(...acknowledgedIds) : 0;
const messageClientId = String(message?.client_id ?? message?.clientId ?? "");
const messageUpdateId = Number(message?.update_id ?? message?.updateId ?? 0);
const outstandingAcknowledged = Boolean(
outstanding
&& (
acknowledgedThrough >= outstanding.updateId
|| (messageClientId === this.clientId && messageUpdateId === outstanding.updateId)
)
);
const pendingOwnerReplacements = mergeOwnerReplacements(
outstanding?.ownerReplacements || [],
buffer?.ownerReplacements || [],
);
let replayOperation = null;
let replayOwnerReplacements = [];
if (this.ready && (outstanding || buffer)) {
const canonicalAuthorship = parseAuthorship(canonicalContent, canonicalOwnerMap);
if (outstandingAcknowledged) {
if (buffer) {
const afterOutstanding = applyOperationToDocument(
this.serverContent,
this.serverOwnerMap,
outstanding.operation,
outstanding.ownerReplacements,
);
const missedRemote = operationFromEdit(
afterOutstanding.content,
canonicalContent,
canonicalAuthorship,
);
[replayOperation] = transformOperations(buffer.operation, missedRemote, false);
replayOwnerReplacements = buffer.ownerReplacements;
}
} else {
const localAuthorship = parseAuthorship(local.content, local.ownerMap);
const localOperation = operationFromEdit(this.serverContent, local.content, localAuthorship);
const missedRemote = operationFromEdit(
this.serverContent,
canonicalContent,
canonicalAuthorship,
);
[replayOperation] = transformOperations(localOperation, missedRemote, false);
replayOwnerReplacements = pendingOwnerReplacements;
}
}
this.initialize(canonicalContent, canonicalOwnerMap, canonicalRevisionId);
if (replayOperation || replayOwnerReplacements.length) {
this.queue(
replayOperation || identityOperation(canonicalContent.length),
replayOwnerReplacements,
);
}
return {
replayed: Boolean(this.buffer),
outstandingAcknowledged,
};
}
adoptCanonicalSnapshot(content, ownerMap, revisionId) {
revisionId = Number(revisionId) || 0;
if (revisionId !== this.revisionId) {
throw new CollaborationRevisionGapError(this.revisionId, revisionId);
}
if (String(content || "") !== this.serverContent) {
throw new Error("Canonical collaborative content does not match applied operations");
}
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
}
}
+331
View File
@@ -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;
}
+197 -20
View File
@@ -11,6 +11,8 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
import { identityOperation, operationFromEdit } from "@rustpad/collaboration";
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
@@ -37,7 +39,11 @@ export function startNoteEditor(adapter) {
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", lastServerContent = "", lastServerOwnerMap = "[]", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
const collaborationClientId = typeof crypto.randomUUID === "function"
? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
const editHistory = {
entries: [],
@@ -971,6 +977,53 @@ export function startNoteEditor(adapter) {
}
function renderNow() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
const render = createRenderQueue(renderNow);
function queueCollaborativeOperation(operation, ownerReplacements = []) {
if (!collaboration.ready || !canEditDocument()) return false;
try {
collaboration.queue(operation, ownerReplacements);
return true;
} catch (error) {
console.error("Failed to queue collaborative operation", error);
saveState.textContent = "Synchronization error";
socket?.stop();
queueMicrotask(connect);
return false;
}
}
function flushCollaborativeUpdate() {
clearTimeout(saveTimer);
if (!canEditDocument()) {
saveState.textContent = "Read only";
return;
}
const pending = collaboration.sendable();
if (!pending) {
if (collaboration.outstanding) {
flushRequested = Boolean(collaboration.buffer);
saveState.textContent = "Saving…";
} else if (!collaboration.buffer) {
flushRequested = false;
saveState.textContent = "Changes are saved automatically";
}
return;
}
const sent = socket?.update(
pending.baseRevisionId,
pending.updateId,
pending.operation,
pending.ownerReplacements,
);
if (!sent) {
flushRequested = true;
saveState.textContent = "Waiting for connection…";
return;
}
collaboration.markSent(pending.updateId);
flushRequested = false;
saveState.textContent = "Saving…";
}
function scheduleDocumentSave() {
clearTimeout(saveTimer);
if (!canEditDocument()) {
@@ -978,10 +1031,19 @@ export function startNoteEditor(adapter) {
return;
}
saveState.textContent = "Saving…";
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
saveTimer = setTimeout(flushCollaborativeUpdate, 250);
}
function queueOwnerReplacement(owner, replacement) {
if (!owner || !replacement) return;
if (queueCollaborativeOperation(identityOperation(editor.value.length), [{ owner, replacement }])) {
scheduleDocumentSave();
}
}
function restoreHistorySnapshot(snapshot) {
if (!snapshot || !canEditDocument()) return;
const previous = editor.value;
const maxOffset = snapshot.content.length;
const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset);
const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset);
@@ -996,6 +1058,7 @@ export function startNoteEditor(adapter) {
syncEditorLayers();
applyingHistory = false;
editor.focus({ preventScroll: true });
queueCollaborativeOperation(operationFromEdit(previous, snapshot.content, authorship));
scheduleDocumentSave();
}
function activeView() {
@@ -1044,9 +1107,7 @@ export function startNoteEditor(adapter) {
if (snapshot.raw) editRawPreviewLine(target);
}
function applyRemote(content, ownerMap) {
lastServerContent = content;
if (ownerMap != null) lastServerOwnerMap = ownerMap;
function applyEditorDocument(content, ownerMap, { resetHistory = true } = {}) {
if (content === editor.value) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
@@ -1066,7 +1127,8 @@ export function startNoteEditor(adapter) {
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
applyingRemote = false;
editHistory.reset();
if (resetHistory) editHistory.reset();
else editHistory.syncCurrent();
render();
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
@@ -1075,6 +1137,108 @@ export function startNoteEditor(adapter) {
requestAnimationFrame(revealLinkedLine);
}
function applyCollaborativeView({ resetHistory = false } = {}) {
const local = collaboration.localDocument();
applyEditorDocument(local.content, local.ownerMap, { resetHistory });
}
function recoverCollaborativeSnapshot(message, reason = "resync") {
try {
const result = collaboration.resynchronize(message);
if (result.replayed) {
flushRequested = true;
toast(reason === "resync" ? "Connection state was resynchronized; pending edits were merged." : "A missed update was merged with your local edits.");
}
} catch (error) {
console.error("Failed to transform pending changes during resynchronization", error);
const localContent = editor.value;
const serverContent = String(message?.content || "");
const serverOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
const revisionId = Number(message?.revision_id) || 0;
const separator = serverContent && !serverContent.endsWith("\n") ? "\n\n" : "";
const recoveryContent = `${serverContent}${separator}<!-- Rustpad local recovery -->\n${localContent}`;
collaboration.initialize(serverContent, serverOwnerMap, revisionId);
if (new TextEncoder().encode(recoveryContent).length <= 2_000_000) {
collaboration.queue(
operationFromEdit(serverContent, recoveryContent, parseAuthorship(recoveryContent, "[]")),
);
flushRequested = true;
toast("A synchronization conflict was preserved as a local recovery block.");
} else {
toast("Synchronization failed because the recoverable document exceeds the size limit.");
}
}
applyCollaborativeView({ resetHistory: true });
if (collaboration.buffer) scheduleDocumentSave();
}
function integrateCollaborativeEnvelope(message, { renderView = true } = {}) {
const result = collaboration.integrate(message);
if (result.duplicate) return result;
if (message.content != null && String(message.content) !== collaboration.serverContent) {
throw new Error("Server snapshot does not match the collaborative operation");
}
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map);
if (renderView) applyCollaborativeView({ resetHistory: result.remote });
return result;
}
function handleCollaborativeAuthentication(message) {
const revisionId = Number(message.revision_id) || 0;
if (!collaboration.ready) {
collaboration.initialize(message.content, message.owner_map, revisionId);
applyCollaborativeView({ resetHistory: true });
return;
}
if (message.resync_required) {
recoverCollaborativeSnapshot(message, "resync");
return;
}
let resetHistory = false;
try {
for (const operation of message.catchup_operations || []) {
const result = integrateCollaborativeEnvelope(operation, { renderView: false });
resetHistory ||= result.remote;
}
collaboration.adoptCanonicalSnapshot(message.content, message.owner_map, revisionId);
applyCollaborativeView({ resetHistory });
} catch (error) {
console.error("Failed to apply collaborative catch-up", error);
recoverCollaborativeSnapshot(message, error instanceof CollaborationRevisionGapError ? "gap" : "resync");
}
}
function resendOutstandingUpdate() {
const pending = collaboration.outstanding;
if (!pending || !canEditDocument()) return;
const sent = socket?.update(
collaboration.revisionId,
pending.updateId,
pending.operation,
pending.ownerReplacements,
);
if (sent) saveState.textContent = "Saving…";
else flushRequested = true;
}
function handleCollaborativeDocument(message) {
try {
const result = integrateCollaborativeEnvelope(message);
if (result.duplicate) return;
const timestamp = new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
if (collaboration.hasPending()) saveState.textContent = "Saving…";
else saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${timestamp}`;
if (result.ownAck && collaboration.buffer && flushRequested) flushCollaborativeUpdate();
} catch (error) {
console.error("Failed to integrate collaborative update", error);
saveState.textContent = "Resynchronizing…";
socket?.stop();
queueMicrotask(connect);
}
}
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken,
canDelete: () => Boolean(info?.can_delete_files),
@@ -1139,6 +1303,8 @@ export function startNoteEditor(adapter) {
color: currentUserColor() || null,
sessionToken: null,
guestId: getGuestId(),
clientId: collaborationClientId,
getKnownRevision: () => collaboration.ready ? collaboration.revisionId : null,
onStatus: handleSocketStatus,
onAuthenticated: message => {
resourceUnlocked = true;
@@ -1146,14 +1312,21 @@ export function startNoteEditor(adapter) {
const readOnly = message.access_level === "read_only";
setDocumentReadOnly(readOnly);
accessLevel.textContent = readOnly ? "Access: read only" : "Access: full";
applyRemote(message.content, message.owner_map);
saveState.textContent = readOnly ? "Read only" : "Changes are saved automatically";
if (!readOnly) editor.focus();
},
onDocument: message => {
applyRemote(message.content, message.owner_map);
saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
if (readOnly) {
collaboration.initialize(message.content, message.owner_map, message.revision_id);
applyCollaborativeView({ resetHistory: true });
flushRequested = false;
saveState.textContent = "Read only";
return;
}
handleCollaborativeAuthentication(message);
if (collaboration.outstanding) resendOutstandingUpdate();
else if (collaboration.buffer) flushCollaborativeUpdate();
else saveState.textContent = "Changes are saved automatically";
editor.focus();
},
onDocument: handleCollaborativeDocument,
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
onPresence: updatePresence,
onLatency: updateLatency,
onDiagnostics: renderConnectionDiagnostics,
@@ -1166,7 +1339,9 @@ export function startNoteEditor(adapter) {
toast(friendly);
accessLevel.textContent = "Access: read only";
setDocumentReadOnly(true, "Read only — changes not saved");
applyRemote(lastServerContent, lastServerOwnerMap);
collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId);
applyCollaborativeView({ resetHistory: true });
flushRequested = false;
queueMicrotask(connect);
return;
}
@@ -1725,7 +1900,7 @@ export function startNoteEditor(adapter) {
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(noteColor);
if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
}
userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value));
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
@@ -1739,7 +1914,7 @@ export function startNoteEditor(adapter) {
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(currentUserColor() || null);
if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
toast("Global profile color restored");
});
editor.addEventListener("keydown", continueIndentation);
@@ -1749,17 +1924,19 @@ export function startNoteEditor(adapter) {
});
editor.addEventListener("input", event => {
if (!canEditDocument() && !applyingRemote) {
applyRemote(lastServerContent, lastServerOwnerMap);
applyCollaborativeView({ resetHistory: true });
saveState.textContent = "Read only";
return;
}
const previous = previousContent;
const nextContent = editor.value;
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner());
authorship = adoptCurrentOwnerAliases(authorship, previous.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
previousContent = nextContent;
render();
if (applyingRemote || applyingHistory) return;
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
editHistory.record(event.inputType || "");
scheduleDocumentSave();
});
+12 -1
View File
@@ -97,6 +97,8 @@ class RoomSocket {
guest_id: this.guestId || null,
color: this.color || null,
diagnostics: this.clientDiagnostics(),
client_id: this.clientId || null,
known_revision_id: this.getKnownRevision?.() ?? null,
});
this.emitDiagnostics();
});
@@ -130,6 +132,7 @@ class RoomSocket {
return;
}
if (message.type === "document") this.onDocument?.(message);
if (message.type === "resync") this.onResync?.(message);
if (message.type === "presence") this.onPresence?.(message.users || []);
if (message.type === "chat") this.onChat?.(message);
if (message.type === "pong") {
@@ -302,7 +305,15 @@ class RoomSocket {
return true;
}
update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
update(baseRevisionId, updateId, operation, ownerReplacements = []) {
return this.send({
type: "update",
base_revision_id: baseRevisionId,
update_id: updateId,
operation,
owner_replacements: ownerReplacements,
});
}
chat(text) { this.send({ type: "chat", text }); }
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); }