v0.2.91
This commit is contained in:
1 parent
4da41a1404
commit
5cb6960b04
31 files changed
+1611
-2123
No files matched your search
+68
-7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user