fixes and functions

This commit is contained in:
Mateusz Gruszczyński
2026-08-04 23:21:27 +02:00
parent 6fc408ddf7
commit 4b25085bb5
28 changed files with 306 additions and 330 deletions
+4 -4
View File
@@ -85,9 +85,9 @@ async function clearSessionIfInvalid() {
}
}
function validateUploadSize(body) {
function validateUploadSize(body, configuredMaxBytes) {
if (!(body instanceof FormData)) return;
const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0);
const maxBytes = Number(configuredMaxBytes ?? window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes ?? 0);
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
for (const value of body.values()) {
if (value instanceof File && value.size > maxBytes) {
@@ -123,7 +123,7 @@ function formDataFileSize(body) {
}
export async function api(path, options = {}) {
validateUploadSize(options.body);
validateUploadSize(options.body, options.uploadMaxSizeBytes);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
@@ -166,7 +166,7 @@ export async function api(path, options = {}) {
}
export function uploadWithProgress(path, options = {}) {
validateUploadSize(options.body);
validateUploadSize(options.body, options.uploadMaxSizeBytes);
const method = options.method || "POST";
const fallbackTotal = formDataFileSize(options.body);
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
-19
View File
@@ -161,25 +161,6 @@ export function mapSelectionThroughEdit(previousText, nextText, start, end = sta
end: Math.max(0, Math.min(nextText.length, map(end))),
};
}
export function lineOwners(content, model) {
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
return starts.map((start, index) => {
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
const totals = new Map();
const representatives = new Map();
for (const span of model?.spans || []) {
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (!overlap) continue;
const identity = ownerIdentity(span.owner);
totals.set(identity, (totals.get(identity) || 0) + overlap);
representatives.set(identity, span.owner);
}
const identity = [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
return identity ? representatives.get(identity) : "";
});
}
export function syncAuthorshipLayer(layer, editor) {
if (!layer || !editor) return;
const canvas = layer.querySelector(".authorship-canvas");
-25
View File
@@ -9,10 +9,6 @@
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
function utf16Length(value) {
return String(value || "").length;
}
function normalizeOwnerSpans(spans, length) {
const result = [];
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
@@ -244,23 +240,6 @@ export function transformOperations(leftOperation, rightOperation, leftBeforeRig
return [{ components: leftPrime }, { components: rightPrime }];
}
export function applyOperation(text, operation) {
text = String(text || "");
operation = normalizeOperation(operation);
if (operationBaseLength(operation) !== text.length) throw new Error("Operation base length does not match document");
let cursor = 0;
let result = "";
for (const component of operation.components) {
if (component.kind === "retain") {
result += text.slice(cursor, cursor + component.count);
cursor += component.count;
} else if (component.kind === "delete") cursor += component.count;
else result += component.text;
}
if (cursor !== text.length) throw new Error("Operation did not consume the whole document");
return result;
}
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
const sourceEnd = sourceStart + length;
for (const span of spans || []) {
@@ -303,10 +282,6 @@ export function applyOperationToDocument(content, ownerMap, operation, ownerRepl
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
}
export function operationEquals(left, right) {
return JSON.stringify(normalizeOperation(left)) === JSON.stringify(normalizeOperation(right));
}
export function compareOperationKeys(left, right) {
const leftClient = String(left?.clientId || left?.client_id || "");
const rightClient = String(right?.clientId || right?.client_id || "");
+1 -1
View File
@@ -65,7 +65,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
if (password.value) payload.password = password.value;
const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.granted); }
window.location.assign(safeAppUrl(`${result.url}?view=split&mode=markdown`));
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
} finally {
-20
View File
@@ -100,26 +100,6 @@ export function buildImageAlias(options = {}) {
return `[${kind}=${filename},${parts.join(",")}]`;
}
export function updateImageAliasInLine(line, aliasIndex, patch = {}) {
const source = String(line || "");
const targetIndex = Number(aliasIndex);
if (!Number.isInteger(targetIndex) || targetIndex < 0) return null;
const codeRanges = inlineCodeRanges(source);
let index = 0;
let changed = false;
const value = source.replace(imageAliasPattern(), (match, ...args) => {
const offset = args.at(-2);
if (isInsideRange(offset, codeRanges) || index++ !== targetIndex) return match;
const parsed = parseImageAlias(match);
if (!parsed) return match;
changed = true;
return buildImageAlias({ ...parsed, ...patch });
});
return changed ? value : null;
}
export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
const source = String(line || "");
const target = String(aliasSource || "");
+23 -6
View File
@@ -119,7 +119,7 @@ export function startNoteEditor(adapter) {
redo() { return this.move(1); },
};
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px) and (orientation: landscape)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
let renderedView = singlePaneQuery.matches ? compactView : uiState.view;
let refreshFilesForAliases = () => { };
@@ -1241,6 +1241,7 @@ export function startNoteEditor(adapter) {
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken,
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
canDelete: () => Boolean(info?.can_delete_files),
canUpload: () => Boolean(info?.can_upload_files),
canEdit: canEditDocument,
@@ -1374,6 +1375,7 @@ export function startNoteEditor(adapter) {
}
async function initialize() {
applyUi();
try {
const session = await validateCurrentSession();
nickname = session?.nickname || getNickname();
@@ -1426,7 +1428,10 @@ export function startNoteEditor(adapter) {
setHeaderMenuOpen(!headerActions.classList.contains("is-open"));
});
headerActions.addEventListener("click", event => {
if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false);
const button = event.target.closest("button");
if (compactLayoutQuery.matches && button && !button.closest(".page-settings-menu")) {
setHeaderMenuOpen(false);
}
});
document.addEventListener("click", event => {
if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false);
@@ -1860,6 +1865,7 @@ export function startNoteEditor(adapter) {
function updatePageControls() {
const passwordProtected = Boolean(info?.protected);
const workspacePassword = adapter.passwordScope === "workspace";
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
if (!passwordProtected) {
publicPageEnabled.checked = false;
unprotectPublicPage.checked = false;
@@ -1868,12 +1874,15 @@ export function startNoteEditor(adapter) {
? "Access to page options requires a password-protected workspace."
: "Access to page options requires a password-protected note.";
if (pagePasswordRequirement) {
pagePasswordRequirement.textContent = requirementText;
pagePasswordRequirement.textContent = canSetPassword
? `Set a ${workspacePassword ? "workspace" : "note"} password here to enable Page publishing.`
: requirementText;
pagePasswordRequirement.hidden = passwordProtected;
}
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Set workspace password" : "Set password";
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword ? "Protects the entire workspace. Minimum 8 characters." : "Minimum 8 characters.";
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Workspace password" : "Note password";
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword
? "At least 8 characters. It protects the workspace and all notes."
: "At least 8 characters. It also protects editing access.";
setPagePasswordForm.hidden = !canSetPassword;
const enabled = passwordProtected && publicPageEnabled.checked;
publicPageEnabled.disabled = !passwordProtected;
@@ -1885,6 +1894,7 @@ export function startNoteEditor(adapter) {
"false",
);
pageSettings?.classList.toggle("is-enabled", enabled);
pageSettings?.classList.toggle("needs-password", canSetPassword);
pageSettings?.querySelector("summary")?.setAttribute(
"title",
!passwordProtected
@@ -1896,6 +1906,11 @@ export function startNoteEditor(adapter) {
: "Published page disabled",
);
}
pageSettings?.addEventListener("toggle", () => {
if (pageSettings.open && !setPagePasswordForm.hidden) {
requestAnimationFrame(() => setPagePasswordInput.focus());
}
});
document.addEventListener("pointerdown", event => {
const target = event.target instanceof Element ? event.target : null;
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
@@ -1929,6 +1944,8 @@ export function startNoteEditor(adapter) {
loadFiles();
connect();
toast("Password set. Page options are now available.");
pageSettings.open = true;
requestAnimationFrame(() => publicPageEnabled.focus());
} catch (error) {
setPagePasswordError.textContent = error.message;
} finally {
+9 -5
View File
@@ -70,7 +70,7 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
@@ -126,6 +126,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
method: "POST",
body: form,
headers: {},
uploadMaxSizeBytes: getUploadMaxSize(),
onProgress: progress => uploadToast.update(progress),
});
if (completed) return;
@@ -143,13 +144,16 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
await run();
}
document.querySelector("#upload-button").addEventListener("click", () => {
function requestUpload() {
if (!canUpload() || !canEdit()) {
toast("Log in with read-write access to upload files.");
toast("You need read-write access and upload permission to upload files.");
return;
}
input.click();
});
}
document.querySelector("#upload-button")?.addEventListener("click", requestUpload);
document.querySelector("#mobile-upload-button")?.addEventListener("click", requestUpload);
input.addEventListener("change", async event => {
let file = event.target.files[0];
@@ -175,7 +179,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
if (!files.length) return;
event.preventDefault();
if (!canUpload() || !canEdit()) {
toast("Log in with read-write access to paste files.");
toast("You need read-write access and upload permission to paste files.");
return;
}
-3
View File
@@ -30,6 +30,3 @@ export function safePublicUrl(value, { allowMailto = true } = {}) {
}
}
export function safeHexColor(value, fallback = "#64748b") {
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value) : fallback;
}
-4
View File
@@ -89,7 +89,3 @@ export function clearAuthSession() {
sessionStorage.removeItem(NICKNAME_KEY);
setNicknameCookie("");
}
export async function resolveIdentity(api, nickname) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname }) });
setNickname(result.nickname); return result;
}
+15 -3
View File
@@ -7,11 +7,15 @@
* See LICENSE file in repository root for details.
*/
const STORAGE_KEY = "rustpad:theme";
const DEFAULT_THEME = "dark";
const THEMES = new Set(["dark", "light"]);
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
function systemTheme() {
return systemThemeQuery.matches ? "light" : "dark";
}
function normalizeTheme(value) {
return THEMES.has(value) ? value : DEFAULT_THEME;
return THEMES.has(value) ? value : systemTheme();
}
function updateBrowserChrome(theme) {
@@ -42,5 +46,13 @@ export function applySessionTheme(session) {
}
window.addEventListener("storage", event => {
if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue, { persist: false });
if (event.key !== STORAGE_KEY) return;
applyTheme(THEMES.has(event.newValue) ? event.newValue : systemTheme(), { persist: false });
});
systemThemeQuery.addEventListener("change", () => {
try {
if (THEMES.has(localStorage.getItem(STORAGE_KEY))) return;
} catch { }
applyTheme(systemTheme(), { persist: false });
});
+6 -1
View File
@@ -10,10 +10,15 @@
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
function defaultEditorView() {
return window.matchMedia("(max-width: 760px)").matches ? "edit" : "split";
}
export function readEditorState() {
const params = new URLSearchParams(window.location.search);
const requestedView = params.get("view");
return {
view: VIEWS.has(params.get("view")) ? params.get("view") : "split",
view: VIEWS.has(requestedView) ? requestedView : defaultEditorView(),
mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown",
};
}
+3 -3
View File
@@ -93,7 +93,7 @@ function renderNotes(notes = notesCache) {
if (notesView === "table") {
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
<tr>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${Number(note.participant_count) || 0}</td>
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
@@ -106,7 +106,7 @@ function renderNotes(notes = notesCache) {
}
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap">
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
@@ -218,7 +218,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
method: "POST",
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
});
location.assign(safeAppUrl(`${note.url}?view=split&mode=markdown`));
location.assign(safeAppUrl(note.url));
} catch (err) { error.textContent = err.message; }
});
notesList.addEventListener("click", async event => {