This commit is contained in:
Mateusz Gruszczyński committed 2026-10-16 22:49:22 +02:00
1 parent 4da41a1404
commit 5cb6960b04
31 files changed
+1611 -2123

No files matched your search

+109 -34
View File
@@ -16,6 +16,7 @@ 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 { buildLineStarts, createVirtualEditorLayers, VIRTUAL_LINE_THRESHOLD } from "@rustpad/virtual-editor-layers";
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
import { createMentionAutocomplete } from "@rustpad/mention-autocomplete";
import { createMentionNavigator } from "@rustpad/mention-navigation";
@@ -52,6 +53,18 @@ export function startNoteEditor(adapter) {
? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId);
const bootLoading = window.__RUSTPAD_LOADING__;
let bootDocumentReady = false;
let bootFilesReady = false;
let bootFinishing = false;
function releaseBootForAccessDialog() {
if (!bootLoading || bootFinishing) return;
bootFinishing = true;
bootLoading.complete("access");
bootLoading.skip("files"); // Identity/password dialog is ready to use.
bootLoading.ready();
}
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 = "", lastRevealedHeadingHash = "", flushRequested = false, accountSession = null;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
let pendingPreviewViewport = null;
@@ -549,6 +562,7 @@ export function startNoteEditor(adapter) {
}
function syncEditorLayers() {
virtualLayers.sync();
gutter.scrollTop = editor.scrollTop;
syncAuthorshipLayer(authorshipLayer, editor);
syncOwnerLabels();
@@ -557,19 +571,34 @@ export function startNoteEditor(adapter) {
let gutterLineCount = 0;
let gutterLineHeight = 0;
let highlightedGutterLine = null;
let cachedLineText = null, cachedLineStarts = [0];
const virtualLayers = createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName });
function updateLinkedLineHighlight() {
const lineCount = cachedLineText === editor.value
? cachedLineStarts.length : Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const linkedLine = lineFromHash(location.hash, lineCount);
if (virtualLayers.active) virtualLayers.highlight(linkedLine);
else {
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
}
highlightedGutterLine = linkedLine;
}
function renderGutter() {
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked);
gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked));
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount });
const content = editor.value;
cachedLineText = content;
cachedLineStarts = buildLineStarts(content);
const lineCount = cachedLineStarts.length;
const owners = authorshipOwners(authorship);
renderedAuthorshipOwners = owners;
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full";
const authorsByLine = showAuthorship && full ? lineAuthors(content, authorship) : [];
authorshipLayer.hidden = !showAuthorship;
ownerLabels.hidden = !full || !showAuthorship;
renderParticipantBadges(authorshipColorsEnabled ? owners : []);
@@ -577,8 +606,26 @@ export function startNoteEditor(adapter) {
editorWorkspace.dataset.authorshipMode = authorshipMode;
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
// Physical rows represent sequential line numbers, not specific text lines.
// Reuse them across character edits and add/remove only the changed tail.
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
if (lineCount >= VIRTUAL_LINE_THRESHOLD) {
if (!virtualLayers.active) gutterLineCount = 0;
virtualLayers.update({
content, lineStarts: cachedLineStarts, model: authorship, authorsByLine,
full, visible: showAuthorship, link: lineFromHash(location.hash, lineCount),
computedStyle: style,
});
updateLinkedLineHighlight();
syncEditorLayers();
return;
}
if (virtualLayers.active) {
virtualLayers.destroy();
gutterLineCount = 0;
gutterLineHeight = 0;
highlightedGutterLine = null;
}
const lines = Array.from({ length: lineCount });
if (gutterLineHeight !== lineHeight) {
for (const row of gutter.children) row.style.height = `${lineHeight}px`;
gutterLineHeight = lineHeight;
@@ -593,8 +640,6 @@ export function startNoteEditor(adapter) {
for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove();
}
gutterLineCount = lineCount;
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
if (full) {
let previousAuthorSignature = null;
ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => {
@@ -610,12 +655,7 @@ export function startNoteEditor(adapter) {
} else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
const linkedLine = lineFromHash(location.hash, lineCount);
if (highlightedGutterLine !== linkedLine) {
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
highlightedGutterLine = linkedLine;
}
updateLinkedLineHighlight();
syncEditorLayers();
}
@@ -644,10 +684,12 @@ export function startNoteEditor(adapter) {
function revealLinkedLine() {
if (!location.hash || location.hash === lastRevealedLineHash) return;
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const hasCurrentIndex = cachedLineText === editor.value;
const lineCount = hasCurrentIndex ? cachedLineStarts.length
: Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const line = lineFromHash(location.hash, lineCount);
if (!line) return;
const offset = lineStartOffset(editor.value, line);
const offset = hasCurrentIndex ? cachedLineStarts[line - 1] : lineStartOffset(editor.value, line);
if (offset == null) return;
const style = getComputedStyle(editor);
@@ -1135,8 +1177,6 @@ export function startNoteEditor(adapter) {
let cachedPreviewHeight = -1;
let cachedPreviewWidth = -1;
function invalidatePreviewPositions() { previewPositionCache = null; }
// The (rect.top - container.top + scrollTop) coordinates are invariant to
// scrolling; only rebuild them when layout/content actually changes.
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(invalidatePreviewPositions).observe(preview);
}
@@ -1149,10 +1189,11 @@ export function startNoteEditor(adapter) {
preview.addEventListener("toggle", invalidatePreviewPositions, true);
preview.addEventListener("load", invalidatePreviewPositions, true);
window.addEventListener("resize", invalidatePreviewPositions);
window.addEventListener("resize", () => {
if (virtualLayers.active) virtualLayers.sync();
});
document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions);
function previewLinePositions() {
// Height/width checks catch dynamic embeds that change geometry without
// a DOM mutation (e.g. a media player expanding after initialization).
const height = preview.scrollHeight, width = preview.clientWidth;
if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache;
const previewRect = preview.getBoundingClientRect();
@@ -1312,11 +1353,9 @@ export function startNoteEditor(adapter) {
let previewStale = true;
let forcePreviewRender = false;
let initialPreviewEnhancements = Promise.resolve();
function renderNow() {
const previewViewport = pendingPreviewViewport;
// In editor-only view this DOM is display:none. Building a full Markdown
// tree (and highlighting / Mermaid) on each remote edit is wasted work.
// Keep the exceptional consumers working: heading deep-links and PDF export.
const headingRevealPending = uiState.mode === "markdown" && location.hash
&& !/^#L[1-9]\d*$/i.test(location.hash)
&& lastRevealedHeadingHash !== location.hash;
@@ -1337,9 +1376,7 @@ export function startNoteEditor(adapter) {
scheduleAliasFileRefresh(editor.value);
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
if (activeView() !== "edit" || previewNeededForPdf) {
renderMermaid();
renderCodeHighlight();
renderMediaPlayers();
initialPreviewEnhancements = Promise.allSettled([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]);
}
} else {
preview.classList.add("preview--raw");
@@ -1360,16 +1397,35 @@ export function startNoteEditor(adapter) {
revealHeadingAnchor();
});
}
async function finishInitialEditorBoot() {
if (!bootLoading || bootFinishing || !bootDocumentReady || !bootFilesReady) return;
bootFinishing = true;
try {
if (deferredRenderTimer !== null) {
clearTimeout(deferredRenderTimer);
deferredRenderTimer = null;
firstDeferredRenderAt = 0;
}
renderNow();
await initialPreviewEnhancements;
await new Promise(resolve => requestAnimationFrame(resolve));
bootLoading.complete("files");
bootLoading.ready();
} catch (error) {
bootLoading.fail(error);
}
}
const render = createRenderQueue(renderNow);
// Collaborative edits can arrive many times per second. Keep typing and
// WebSocket processing responsive by batching expensive Markdown/DOM work.
let deferredRenderTimer = null;
let firstDeferredRenderAt = 0;
let lastLocalInputAt = -Infinity;
function scheduleDocumentRender() {
const now = performance.now();
if (deferredRenderTimer === null) firstDeferredRenderAt = now;
else clearTimeout(deferredRenderTimer);
const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
const activeTyping = now - lastLocalInputAt < 450;
const delay = activeTyping ? 300 : Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
deferredRenderTimer = setTimeout(() => {
deferredRenderTimer = null;
firstDeferredRenderAt = 0;
@@ -1607,7 +1663,7 @@ export function startNoteEditor(adapter) {
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 });
if (renderView && !result.ownAck) applyCollaborativeView({ resetHistory: result.remote });
return result;
}
@@ -1665,8 +1721,7 @@ export function startNoteEditor(adapter) {
}
}
const { loadFiles } = bindNoteFiles({
const { loadFiles, initialFilesReady } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken,
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
canDelete: () => Boolean(info?.can_delete_files),
@@ -1753,6 +1808,8 @@ export function startNoteEditor(adapter) {
applyCollaborativeView({ resetHistory: true });
flushRequested = false;
saveState.textContent = "Read only";
bootDocumentReady = true;
void finishInitialEditorBoot();
return;
}
handleCollaborativeAuthentication(message);
@@ -1760,6 +1817,8 @@ export function startNoteEditor(adapter) {
else if (collaboration.buffer) flushCollaborativeUpdate();
else saveState.textContent = "Changes are saved automatically";
editor.focus();
bootDocumentReady = true;
void finishInitialEditorBoot();
},
onDocument: handleCollaborativeDocument,
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
@@ -1780,6 +1839,7 @@ export function startNoteEditor(adapter) {
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
releaseBootForAccessDialog();
try {
await loadNoteInfo();
adapter.configureView?.(info);
@@ -1803,6 +1863,7 @@ export function startNoteEditor(adapter) {
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
if (/nickname|session|account/i.test(message)) {
if (!identityDialog.open) identityDialog.showModal();
releaseBootForAccessDialog();
return;
}
if (info?.protected && isResourceAccessError(message)) {
@@ -1811,6 +1872,7 @@ export function startNoteEditor(adapter) {
document.querySelector("#password-error").textContent = friendly;
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
releaseBootForAccessDialog();
return;
}
toast.danger(friendly, { title: "Editor connection error" });
@@ -1842,23 +1904,35 @@ export function startNoteEditor(adapter) {
nickname = accountSession?.nickname || getNickname();
if (!nickname) {
if (!identityDialog.open) identityDialog.showModal();
releaseBootForAccessDialog();
return;
}
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
await loadNoteInfo();
bootLoading?.complete("access");
document.title = adapter.title(info);
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
adapter.configureView?.(info);
applyUi({ write: true, replace: true });
updateCurrentUser();
if (info.protected && info.access_level === "none") passwordDialog.showModal();
else { loadFiles(); connect(); }
if (info.protected && info.access_level === "none") {
passwordDialog.showModal();
releaseBootForAccessDialog();
} else {
void loadFiles();
void initialFilesReady.then(() => {
bootFilesReady = true;
void finishInitialEditorBoot();
});
connect();
}
} catch (e) {
if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
return;
}
bootLoading?.fail(e);
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
}
}
@@ -2159,7 +2233,7 @@ export function startNoteEditor(adapter) {
}
}
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; updateLinkedLineHighlight(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
preview.addEventListener("click", event => {
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
if (!link) return;
@@ -2626,6 +2700,7 @@ export function startNoteEditor(adapter) {
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
previousContent = nextContent;
if (!applyingRemote && !applyingHistory) lastLocalInputAt = performance.now();
scheduleDocumentRender();
if (applyingRemote || applyingHistory) return;
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));