fix in db and collaborate
This commit is contained in:
+197
-20
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user