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

+68 -7
View File
@@ -60,7 +60,61 @@ export function parseAuthorship(content, raw) {
}
export function serializeAuthorship(model, contentLength) {
return JSON.stringify({ version: VERSION, spans: normalize(model?.spans, contentLength) });
const canonical = canonicalSpans(model, contentLength);
const spans = canonical
? canonical.map(span => ({ start: span.start, end: span.end, owner: span.owner }))
: normalize(model?.spans, contentLength);
return JSON.stringify({ version: VERSION, spans });
}
function canonicalSpans(model, length) {
const spans = model?.spans;
if (!Array.isArray(spans)) return null;
let previousEnd = 0, previousOwner = "";
for (const span of spans) {
if (!span || typeof span.owner !== "string" || !span.owner
|| !Number.isFinite(span.start) || !Number.isFinite(span.end)
|| span.start < previousEnd || span.end <= span.start || span.end > length
|| (span.start === previousEnd && span.owner === previousOwner)) return null;
previousEnd = span.end;
previousOwner = span.owner;
}
return spans;
}
function appendCanonicalSpan(result, start, end, owner) {
if (!owner || end <= start) return;
const previous = result.at(-1);
if (previous?.owner === owner && start <= previous.end) {
previous.end = Math.max(previous.end, end);
} else result.push({ start, end, owner });
}
function fastAuthorshipEdit(spans, prefix, oldSuffix, insertedLength, delta, owner) {
const result = [];
let inserted = false;
const insert = () => {
if (!inserted) {
appendCanonicalSpan(result, prefix, prefix + insertedLength, owner ? String(owner) : "");
inserted = true;
}
};
for (const span of spans) {
if (span.end <= prefix) {
appendCanonicalSpan(result, span.start, span.end, span.owner);
} else if (span.start >= oldSuffix) {
insert();
appendCanonicalSpan(result, span.start + delta, span.end + delta, span.owner);
} else {
if (span.start < prefix) appendCanonicalSpan(result, span.start, prefix, span.owner);
insert();
if (span.end > oldSuffix) {
appendCanonicalSpan(result, prefix + insertedLength, span.end + delta, span.owner);
}
}
}
insert();
return { version: VERSION, spans: result };
}
export function applyAuthorshipEdit(model, previousText, nextText, owner) {
@@ -79,6 +133,8 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
const removedLength = oldSuffix - prefix;
const insertedLength = newSuffix - prefix;
const delta = insertedLength - removedLength;
const canonical = canonicalSpans(model, previousText.length);
if (canonical) return fastAuthorshipEdit(canonical, prefix, oldSuffix, insertedLength, delta, owner);
const updated = [];
for (const source of normalize(model?.spans, previousText.length)) {
@@ -99,13 +155,22 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
}
export function replaceAuthorshipOwner(model, matcher, replacement, contentLength) {
const canonical = canonicalSpans(model, contentLength);
if (canonical) {
const spans = [];
for (const span of canonical) {
const owner = matcher(span.owner) ? String(replacement || "") : span.owner;
appendCanonicalSpan(spans, span.start, span.end, owner);
}
return { version: VERSION, spans };
}
return {
version: VERSION,
spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength),
};
}
export function authorshipOwners(model) {
const owners = new Map();
for (const span of model?.spans || []) {
@@ -120,13 +185,11 @@ export function lineAuthors(content, model) {
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
// Parsed authorship spans are sorted and non-overlapping. Retain the old
// result for non-canonical callers instead of silently changing semantics.
let canonical = true;
for (let i = 0, previousEnd = 0; i < spans.length; i++) {
const span = spans[i];
if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end)
|| span.start < previousEnd || span.end < span.start) {
|| span.start < previousEnd || span.end < span.start) {
canonical = false;
break;
}
@@ -187,8 +250,6 @@ export function syncAuthorshipLayer(layer, editor) {
const canvas = layer.querySelector(".authorship-canvas");
if (!canvas) return;
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`;
// The canvas already mirrors the textarea scroll through its transform.
// Keeping the overlay element itself at zero prevents a double offset.
layer.scrollTop = 0;
layer.scrollLeft = 0;
}
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @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.
*/
(() => {
const order = ["assets", "language", "libraries", "access", "files"];
const resourceParts = new Set();
const locale = String(document.documentElement.lang || "en").toLowerCase().startsWith("pl") ? "pl" : "en";
const strings = {
en: {
assets: "Preparing the interface", language: "Setting up language",
libraries: "Starting the editor", access: "Checking access", files: "Opening the note",
complete: "Ready", error: "The note could not be opened", retry: "Reload page",
errorDetails: "Check your connection and try again.", progress: "{done} of {total} stages completed",
timeout: "Still waiting for the note. Check the connection or reload."
},
pl: {
assets: "Przygotowanie interfejsu", language: "Ustawianie j\u0119zyka",
libraries: "Uruchamianie edytora", access: "Sprawdzanie dost\u0119pu", files: "Otwieranie notatki",
complete: "Gotowe", error: "Nie uda\u0142o si\u0119 otworzy\u0107 notatki", retry: "Za\u0142aduj ponownie",
errorDetails: "Sprawd\u017a po\u0142\u0105czenie i spr\u00f3buj ponownie.", progress: "Uko\u0144czono {done} z {total} etap\u00f3w",
timeout: "Nadal oczekujemy na notatk\u0119. Sprawd\u017a po\u0142\u0105czenie lub od\u015bwie\u017c stron\u0119."
}
}[locale];
const completed = new Set();
const loadedStyleElements = new WeakSet();
let loadedStyleCount = 0;
let readyRequested = false;
let dismissed = false;
let failure = "";
const element = id => document.getElementById(id);
function update() {
const overlay = element("app-boot-overlay");
if (!overlay || dismissed) return;
const count = completed.size;
const progress = Math.round((count / order.length) * 100);
const next = order.find(stage => !completed.has(stage));
const heading = element("app-boot-heading");
if (heading) heading.textContent = locale === "pl" ? "Ładowanie…" : "Loading…";
const stageText = element("app-boot-stage");
const stageCount = element("app-boot-count");
const bar = element("app-boot-progress");
const fill = element("app-boot-progress-fill");
const retry = element("app-boot-retry");
if (stageText) stageText.textContent = failure ? strings.error : (next ? strings[next] : strings.complete);
if (stageCount) stageCount.textContent = failure || strings.progress.replace("{done}", count).replace("{total}", order.length);
if (bar) bar.setAttribute("aria-valuenow", String(progress));
if (fill) fill.style.width = `${progress}%`;
if (retry) { retry.hidden = !failure; retry.textContent = strings.retry; }
overlay.classList.toggle("has-error", Boolean(failure));
for (const stage of order) {
const node = element(`app-boot-step-${stage}`);
if (!node) continue;
const isDone = completed.has(stage);
node.dataset.status = isDone ? "done" : (stage === next && !failure ? "active" : "waiting");
node.querySelector("span")?.replaceChildren(document.createTextNode(strings[stage]));
}
if (readyRequested && count === order.length && !failure) dismiss();
}
function dismiss() {
if (dismissed) return;
dismissed = true;
const overlay = element("app-boot-overlay");
if (!overlay) return;
overlay.classList.add("is-ready");
const remove = () => overlay.remove();
overlay.addEventListener("transitionend", remove, { once: true });
setTimeout(remove, 300);
}
function complete(stage) {
if (dismissed || failure) return;
if (stage === "css" || stage === "js") {
resourceParts.add(stage);
if (resourceParts.has("css") && resourceParts.has("js")) completed.add("assets");
} else if (order.includes(stage)) {
completed.add(stage);
} else {
return;
}
update();
}
function fail(reason) {
if (dismissed || failure) return;
const safeMessage = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "";
failure = safeMessage && safeMessage.length < 180 ? `${strings.error}: ${safeMessage}` : strings.errorDetails;
update();
}
document.addEventListener("load", event => {
if (!(event.target instanceof HTMLLinkElement) || !event.target.matches("link[data-rustpad-shell-css]")) return;
if (loadedStyleElements.has(event.target)) return;
loadedStyleElements.add(event.target);
loadedStyleCount += 1;
const expected = document.querySelectorAll("link[data-rustpad-shell-css]").length;
if (expected > 0 && loadedStyleCount >= expected) complete("css");
}, true);
document.addEventListener("error", event => {
if (event.target instanceof HTMLLinkElement && event.target.matches("link[data-rustpad-shell-css]")) {
fail(strings.errorDetails);
}
}, true);
document.addEventListener("DOMContentLoaded", () => {
element("app-boot-retry")?.addEventListener("click", () => location.reload());
update();
}, { once: true });
const timeout = setTimeout(() => {
if (!dismissed) fail(strings.timeout);
}, 45000);
window.__RUSTPAD_LOADING__ = Object.freeze({
complete,
skip: complete,
ready() { readyRequested = true; update(); if (dismissed) clearTimeout(timeout); },
fail,
stageDone(stage) {
return stage === "css" || stage === "js" ? resourceParts.has(stage) : completed.has(stage);
}
});
})();
+7 -2
View File
@@ -105,10 +105,15 @@ export class CollaborationSession {
const replacements = mergeOwnerReplacements([], ownerReplacements);
if (!hasTextEffect(operation) && !replacements.length) return null;
const localLength = this.localDocument().content.length;
const localLength = this.buffer
? operationTargetLength(this.buffer.operation)
: this.outstanding
? operationTargetLength(this.outstanding.operation)
: this.serverContent.length;
if (operationBaseLength(operation) !== localLength) {
throw new Error("Local operation base length does not match the collaborative document");
}
const targetLength = operationTargetLength(operation);
if (!this.buffer) {
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
@@ -117,7 +122,7 @@ export class CollaborationSession {
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
}
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
if (operationTargetLength(this.buffer.operation) !== targetLength) {
throw new Error("Buffered operation target length does not match the collaborative document");
}
return this.buffer;
+20 -3
View File
@@ -100,6 +100,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken();
const identityDialog = document.querySelector("#identity-dialog");
const accountSlot = document.querySelector("#footer-account-slot");
const guestAccount = document.querySelector("#footer-account-guest");
const userAccount = document.querySelector("#footer-account-user");
const userLabel = document.querySelector("#footer-user-label");
@@ -121,6 +122,7 @@ const aboutDialog = document.querySelector("#about-dialog");
const profileForm = document.querySelector("#profile-form");
let currentSession = null;
let accountStateRevision = 0;
function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
@@ -440,6 +442,7 @@ resourcesPagination?.addEventListener("click", event => { const button = event.t
function renderAccount(session) {
accountStateRevision += 1;
currentSession = session;
guestAccount.hidden = Boolean(session);
userAccount.hidden = !session;
@@ -473,6 +476,13 @@ function renderAccount(session) {
userLabelSecondary.hidden = true;
}
registerLink.hidden = !registrationEnabled;
if (accountSlot) {
accountSlot.dataset.state = "ready";
accountSlot.setAttribute("aria-busy", "false");
const retry = document.querySelector("#footer-account-retry");
if (retry) retry.hidden = true;
}
}
document.querySelector("#footer-about")?.addEventListener("click", () => aboutDialog?.showModal());
@@ -523,7 +533,7 @@ if (identityDialog) {
profileDialog.showModal();
});
document.addEventListener("rustpad:languagechange", () => {
renderAccount(currentSession);
if (accountSlot?.dataset.state === "ready") renderAccount(currentSession);
if (!profileDialog?.open) return;
populateLanguageSelect(document.querySelector("#profile-language"), getLanguage());
});
@@ -601,6 +611,13 @@ if (identityDialog) {
});
window.addEventListener("rustpad:session-expired", () => renderAccount(null));
renderAccount(null);
validateCurrentSession().then(renderAccount);
const lookupRevision = accountStateRevision;
validateCurrentSession()
.then(session => {
if (accountStateRevision === lookupRevision) renderAccount(session);
})
.catch(error => {
console.warn("Could not validate the saved session", error);
if (accountStateRevision === lookupRevision) renderAccount(null);
});
}
+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));
+5 -1
View File
@@ -182,6 +182,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
let currentFiles = [];
let filesLoaded = false;
let loadSequence = 0;
let finishInitialFiles;
const initialFilesReady = new Promise(resolve => { finishInitialFiles = resolve; });
function summaryText(files, shown = files.length) {
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
@@ -279,9 +281,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
renderFiles();
onFilesChanged(currentFiles);
finishInitialFiles();
} catch (error) {
if (sequence !== loadSequence) return;
renderLoadError(error);
finishInitialFiles();
if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") });
}
}
@@ -500,5 +504,5 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
}
});
return { loadFiles };
return { loadFiles, initialFilesReady };
}
+9 -1
View File
@@ -128,7 +128,7 @@ function settleInitialPublicAnchor(hash) {
window.addEventListener("load", scheduleCorrection, { once: true });
cleanups.push(() => window.removeEventListener("load", scheduleCorrection));
}
document.fonts?.ready?.then(scheduleCorrection).catch(() => {});
document.fonts?.ready?.then(scheduleCorrection).catch(() => { });
if (typeof ResizeObserver === "function") {
observer = new ResizeObserver(scheduleCorrection);
@@ -183,6 +183,7 @@ function restoreFullWidth() {
async function initialize() {
try {
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
window.__RUSTPAD_LOADING__?.complete("access");
if (passwordDialog.open) passwordDialog.close();
passwordError.textContent = "";
publicTitle.textContent = page.title;
@@ -197,12 +198,17 @@ async function initialize() {
installPublicPermalinks();
alignPreviewLineNumbers(content);
settleInitialPublicAnchor(location.hash);
window.__RUSTPAD_LOADING__?.complete("files");
window.__RUSTPAD_LOADING__?.ready();
} catch (error) {
window.__RUSTPAD_LOADING__?.complete("access");
publicAnchorSettleCleanup?.();
if (error.status === 401 || error.status === 403) {
passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password.");
if (!passwordDialog.open) passwordDialog.showModal();
passwordInput.focus();
window.__RUSTPAD_LOADING__?.skip("files");
window.__RUSTPAD_LOADING__?.ready();
return;
}
content.replaceChildren();
@@ -211,6 +217,8 @@ async function initialize() {
message.textContent = String(error.message);
content.append(message);
toast.danger(error.message, { title: "Could not load published page" });
window.__RUSTPAD_LOADING__?.skip("files");
window.__RUSTPAD_LOADING__?.ready();
}
}
content.addEventListener("click", async event => {
+263
View File
@@ -0,0 +1,263 @@
/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Virtualize only decorative editor layers. The textarea, OT document,
* preview renderer, selection, history, and line link URLs are unchanged.
*/
export const VIRTUAL_LINE_THRESHOLD = 1500;
const OVERSCAN = 8;
export function buildLineStarts(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) starts.push(i + 1);
}
return starts;
}
export function visibleLineWindow(scrollTop, viewportHeight, paddingTop, lineHeight, total, overscan = OVERSCAN) {
const height = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
const top = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0;
const rowHeight = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
const offset = Number.isFinite(paddingTop) ? paddingTop : 0;
const from = Math.max(0, Math.floor((top - offset) / rowHeight));
const to = Math.max(from + 1, Math.ceil((top + height - offset) / rowHeight));
return {
first: Math.max(0, Math.min(total, from - overscan)),
end: Math.max(0, Math.min(total, to + overscan)),
};
}
export function labelGroupLines(authorsByLine, ownerName) {
const groups = [];
let previousSignature = null;
for (let i = 0; i < authorsByLine.length; i++) {
const authors = authorsByLine[i] || [];
if (!authors.length) continue;
const signature = authors.map(ownerName).sort((a, b) => a.localeCompare(b)).join('\u0000');
if (signature === previousSignature) continue;
previousSignature = signature;
groups.push({ line: i, authors });
}
return groups;
}
function lowerBound(items, value, selector = item => item) {
let left = 0, right = items.length;
while (left < right) {
const mid = (left + right) >>> 1;
if (selector(items[mid]) < value) left = mid + 1;
else right = mid;
}
return left;
}
export function createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName }) {
let active = false;
let starts = [0], text = '', spans = [], groups = [];
let showAuthorship = false, showLabels = false, linkedLine = null;
let lineHeight = 20, paddingTop = 0, paddingLeft = 0;
let spacer, gutterWindow, labelCanvas, colorCanvas, colorViewport;
let renderedFirst = -1, renderedEnd = -1;
let lineNodes = new Map(), labelNodes = new Map();
function initialize() {
if (active) return;
active = true;
spacer = document.createElement('div');
spacer.className = 'line-gutter-virtual-spacer';
spacer.style.position = 'relative';
spacer.style.width = '100%';
gutterWindow = document.createElement('div');
gutterWindow.className = 'line-gutter-virtual-window';
gutterWindow.style.position = 'absolute';
gutterWindow.style.left = '0';
gutterWindow.style.right = '0';
spacer.append(gutterWindow);
gutter.replaceChildren(spacer);
labelCanvas = document.createElement('div');
labelCanvas.className = 'owner-labels-canvas';
ownerLabels.replaceChildren(labelCanvas);
colorCanvas = document.createElement('div');
colorCanvas.className = 'authorship-canvas';
colorViewport = document.createElement('div');
colorViewport.className = 'authorship-viewport';
colorViewport.style.position = 'absolute';
colorViewport.style.whiteSpace = 'pre';
colorCanvas.append(colorViewport);
authorshipLayer.replaceChildren(colorCanvas);
}
function createLineNode() {
const row = document.createElement('div');
row.className = 'line-number-row';
const button = document.createElement('button');
button.className = 'line-number-button';
button.type = 'button';
button.tabIndex = -1;
row.append(button);
return row;
}
function updateGutter(first, end, force) {
if (!force && first === renderedFirst && end === renderedEnd) return;
const available = [...lineNodes.values()].filter(node => {
const line = Number(node.firstElementChild.dataset.line);
return line < first + 1 || line > end;
});
const next = new Map(), fragment = document.createDocumentFragment();
for (let i = first; i < end; i++) {
const line = i + 1;
const row = lineNodes.get(line) || available.pop() || createLineNode();
row.style.height = `${lineHeight}px`;
const button = row.firstElementChild;
if (button.dataset.line !== String(line)) {
button.dataset.line = String(line);
button.textContent = String(line);
button.setAttribute('aria-label', `Copy link to line ${line}`);
button.classList.remove('is-copied');
}
button.classList.toggle('is-linked', linkedLine === line);
next.set(line, row);
fragment.append(row);
}
gutterWindow.style.top = `${first * lineHeight}px`;
gutterWindow.replaceChildren(fragment);
lineNodes = next;
}
function createLabelNode(group) {
const node = document.createElement('span');
node.className = 'owner-label-group';
for (const owner of group.authors) {
const badge = document.createElement('span');
badge.className = 'owner-label';
badge.style.setProperty('--owner', colorFor(owner));
badge.textContent = ownerName(owner);
node.append(badge);
}
return node;
}
function updateLabels(first, end, force) {
if (!showLabels) {
if (labelNodes.size) labelCanvas.replaceChildren();
labelNodes.clear();
return;
}
if (!force && first === renderedFirst && end === renderedEnd) return;
const next = new Map(), fragment = document.createDocumentFragment();
for (let i = lowerBound(groups, first, item => item.line); i < groups.length && groups[i].line < end; i++) {
const group = groups[i];
const node = labelNodes.get(group.line) || createLabelNode(group);
node.style.top = `${paddingTop + group.line * lineHeight + lineHeight / 2}px`;
next.set(group.line, node);
fragment.append(node);
}
labelCanvas.replaceChildren(fragment);
labelNodes = next;
}
function updateColors(first, end, force) {
if (!showAuthorship) {
if (colorViewport.hasChildNodes()) colorViewport.replaceChildren();
return;
}
if (!force && first === renderedFirst && end === renderedEnd) return;
const from = starts[first] ?? text.length;
const to = end < starts.length ? starts[end] : text.length;
const fragment = document.createDocumentFragment();
let cursor = from;
let index = lowerBound(spans, from + 1, item => item.end);
while (index < spans.length) {
const span = spans[index++];
if (span.start >= to) break;
const start = Math.max(cursor, span.start, from);
const stop = Math.min(span.end, to);
if (start > cursor) fragment.append(document.createTextNode(text.slice(cursor, start)));
if (stop <= start) continue;
const mark = document.createElement('span');
mark.className = 'authorship-fragment';
mark.style.setProperty('--owner', colorFor(span.owner));
mark.textContent = text.slice(start, stop);
mark.title = String(span.owner).split('\u001f', 1)[0];
fragment.append(mark);
cursor = stop;
}
if (cursor < to) fragment.append(document.createTextNode(text.slice(cursor, to)));
if (end === starts.length && !text.endsWith('\n')) fragment.append(document.createTextNode('\n'));
colorViewport.style.top = `${paddingTop + first * lineHeight}px`;
colorViewport.style.left = `${paddingLeft}px`;
colorViewport.replaceChildren(fragment);
}
function updateViewport(force = false) {
if (!active) return;
const { first, end } = visibleLineWindow(
editor.scrollTop, editor.clientHeight, paddingTop, lineHeight, starts.length,
);
if (!force && first === renderedFirst && end === renderedEnd) return;
updateGutter(first, end, force);
updateLabels(first, end, force);
updateColors(first, end, force);
renderedFirst = first;
renderedEnd = end;
}
return {
get active() { return active; },
update({ content, lineStarts, model, authorsByLine, full, visible, link, computedStyle }) {
initialize();
text = content;
starts = lineStarts;
spans = model?.spans || [];
showAuthorship = visible;
showLabels = visible && full;
linkedLine = link;
const style = computedStyle;
lineHeight = parseFloat(style.lineHeight) || 29;
paddingTop = parseFloat(style.paddingTop) || 24;
paddingLeft = parseFloat(style.paddingLeft) || 0;
spacer.style.height = `${starts.length * lineHeight}px`;
gutterWindow.style.lineHeight = `${lineHeight}px`;
colorCanvas.style.paddingTop = style.paddingTop;
colorCanvas.style.paddingRight = style.paddingRight;
colorCanvas.style.paddingBottom = style.paddingBottom;
colorCanvas.style.paddingLeft = style.paddingLeft;
colorCanvas.style.fontFamily = style.fontFamily;
colorCanvas.style.fontSize = style.fontSize;
colorCanvas.style.fontWeight = style.fontWeight;
colorCanvas.style.lineHeight = style.lineHeight;
colorCanvas.style.letterSpacing = style.letterSpacing;
colorCanvas.style.minWidth = `${editor.scrollWidth}px`;
colorCanvas.style.minHeight = `${editor.scrollHeight}px`;
authorshipLayer.style.left = `${editor.offsetLeft}px`;
groups = showLabels ? labelGroupLines(authorsByLine, ownerName) : [];
labelNodes = new Map();
updateViewport(true);
},
sync() { updateViewport(); },
highlight(line) {
linkedLine = line;
for (const [number, row] of lineNodes) {
row.firstElementChild.classList.toggle('is-linked', number === line);
}
},
destroy() {
if (!active) return;
active = false;
gutter.replaceChildren();
ownerLabels.replaceChildren();
authorshipLayer.replaceChildren();
lineNodes = new Map();
labelNodes = new Map();
renderedFirst = renderedEnd = -1;
groups = [];
spans = [];
text = '';
},
get range() { return { first: renderedFirst, end: renderedEnd }; },
};
}
+47 -11
View File
@@ -489,7 +489,17 @@ workspaceSettingsForm.addEventListener("submit", async event => {
submit.disabled = false;
}
});
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
function updateNoteProtectionExplanation() {
const message = document.querySelector("#note-archive-explanation");
if (!message) return;
message.textContent = info?.archive_deleted_notes
? t("workspace.noteProtection.archiveEnabled", {}, "Archiving is enabled: authorized deletion moves the note, its history and files to Archived notes. The workspace owner can restore it.")
: t("workspace.noteProtection.archiveDisabled", {}, "Archiving is disabled: authorized deletion permanently removes the note and its files. Enable archiving in Workspace settings if recovery is important.");
}
document.querySelector("#new-note-button").addEventListener("click", () => {
updateNoteProtectionExplanation();
document.querySelector("#note-dialog").showModal();
});
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
document.querySelector("#note-form").addEventListener("submit", async e => {
e.preventDefault();
@@ -542,21 +552,47 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async (
catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); }
});
async function startAuthorizedWorkspace() {
const session = await validateCurrentSession();
accountSession = session;
nickname = session?.nickname || getNickname();
const loading = window.__RUSTPAD_LOADING__;
document.querySelector("#workspace-startup-error")?.setAttribute("hidden", "");
try {
const session = await validateCurrentSession();
accountSession = session;
nickname = session?.nickname || getNickname();
if (!nickname) {
if (!nickname) {
loading?.complete("access");
workspaceContent.hidden = true;
if (!identityDialog.open) identityDialog.showModal();
loading?.skip("files");
loading?.ready();
return;
}
accessToken = shareToken || getAccessToken("workspace", slug);
workspaceContent.hidden = false;
await init();
loading?.complete("access");
if (workspaceLockedForPassword) loading?.skip("files");
else loading?.complete("files");
loading?.ready();
} catch (error) {
loading?.fail(error);
console.error("Workspace initialization failed", error);
workspaceContent.hidden = true;
if (!identityDialog.open) identityDialog.showModal();
return;
const panel = document.querySelector("#workspace-startup-error");
const message = document.querySelector("#workspace-startup-message");
if (message) message.textContent = `${t("workspace.connectionError", {}, "Workspace connection error")}: ${error?.message || "Please retry."}`;
if (panel) panel.hidden = false;
}
accessToken = shareToken || getAccessToken("workspace", slug);
workspaceContent.hidden = false;
await init();
}
document.querySelector("#workspace-startup-retry")?.addEventListener("click", async event => {
const button = event.currentTarget;
button.disabled = true;
try { await startAuthorizedWorkspace(); }
finally { button.disabled = false; }
});
bindIdentityDialog({
dialog: identityDialog,
onIdentity: async (value, session) => {