new functions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 22:13:04 +02:00
parent 9fc32d0d31
commit e842b26978
34 changed files with 2505 additions and 115 deletions
+18 -2
View File
@@ -119,6 +119,20 @@ let currentSession = null;
function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
function resourceActionLabel(full, short = full) {
return `<span class="resource-action-label resource-action-label--full">${escapeHtml(full)}</span><span class="resource-action-label resource-action-label--short" aria-hidden="true">${escapeHtml(short)}</span>`;
}
function setResourcePrivacyLabel(button, isPrivate) {
if (!button) return;
const full = isPrivate ? "Make public" : "Make private";
const short = isPrivate ? "Public" : "Private";
button.setAttribute("aria-label", full);
button.title = full;
const fullLabel = button.querySelector(".resource-action-label--full");
const shortLabel = button.querySelector(".resource-action-label--short");
if (fullLabel) fullLabel.textContent = full;
if (shortLabel) shortLabel.textContent = short;
}
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
@@ -157,7 +171,9 @@ async function loadResources() {
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button">Password…<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const privacyAction = item.private ? "Make public" : "Make private";
const privacyShort = item.private ? "Public" : "Private";
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -175,7 +191,7 @@ async function loadResources() {
try {
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
item.private = nextPrivate;
button.textContent = nextPrivate ? "Make public" : "Make private";
setResourcePrivacyLabel(button, nextPrivate);
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
} catch (e) {
+80 -3
View File
@@ -43,6 +43,51 @@ function safeUrl(value) {
}
}
function safeAttachmentUrl(file, { download = false } = {}) {
let raw = String(file?.url || "").trim();
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return "#";
if (download && route) url.searchParams.set("download", "1");
return escapeHtml(url.href);
} catch {
return "#";
}
}
function safeAttachmentPlaybackUrl(file) {
return safeAttachmentUrl(file);
}
function safeAttachmentDownloadUrl(file) {
return safeAttachmentUrl(file, { download: true });
}
function youtubeVideo(value) {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return null;
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const host = url.hostname.toLowerCase();
let id = "";
if (host === "youtu.be") {
id = url.pathname.split("/").filter(Boolean)[0] || "";
} else if (host === "youtube.com" || host.endsWith(".youtube.com") || host === "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com")) {
if (url.pathname === "/watch") id = url.searchParams.get("v") || "";
else {
const match = url.pathname.match(/^\/(?:shorts|embed|live)\/([A-Za-z0-9_-]+)/);
id = match?.[1] || "";
}
}
return /^[A-Za-z0-9_-]{6,20}$/.test(id) ? { id, url: url.href } : null;
} catch {
return null;
}
}
export function setMarkdownFiles(files) {
const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
@@ -60,7 +105,7 @@ export function setMarkdownFiles(files) {
export function unresolvedMarkdownFileAliases(value) {
const missing = new Set();
const source = String(value || "").replace(/`[^`]*`/g, "");
for (const match of source.matchAll(/\[(?:file|image|img)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
for (const match of source.matchAll(/\[(?:file|image|img|video)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
@@ -76,13 +121,18 @@ function inline(value) {
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
html = html.replace(/\[(file|image|img|video)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
const normalizedKind = kind.toLowerCase();
const file = markdownFiles.get(filename);
if (!file) return match;
if (normalizedKind === "file") {
const text = String(label || filename).trim() || filename;
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
return stash(`<a href="${safeAttachmentDownloadUrl(file)}" download="${escapeHtml(filename)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${escapeHtml(filename)}">${text}</a>`);
}
if (normalizedKind === "video") {
if (!file.mimeType.startsWith("video/")) return match;
const text = String(label || filename).trim() || filename;
return stash(`<a href="${safeAttachmentPlaybackUrl(file)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="video" data-file-name="${escapeHtml(filename)}">${text}</a>`);
}
if (!file.mimeType.startsWith("image/")) return match;
@@ -162,6 +212,26 @@ function normalizeLanguage(value) {
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
function renderStandaloneMedia(line, sourceLine) {
const videoAlias = String(line).trim().match(/^\[video=([^,\]\s]+)(?:,([^\]]*))?\]$/i);
if (videoAlias) {
const filename = videoAlias[1];
const file = markdownFiles.get(filename);
if (!file || !file.mimeType.startsWith("video/")) return null;
const label = String(videoAlias[2] || filename).trim() || filename;
const playbackUrl = safeAttachmentPlaybackUrl(file);
const downloadUrl = safeAttachmentDownloadUrl(file);
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>Playback is unavailable. <a href="${downloadUrl}" download="${escapeHtml(filename)}">Download ${escapeHtml(label)}</a>.</p></div>`;
}
const trimmed = String(line).trim();
const markdownLink = trimmed.match(/^\[([^\]]+)\]\(([^\s)]+)(?:\s+["'][^"']*["'])?\)$/);
const candidate = markdownLink ? markdownLink[2] : trimmed;
const youtube = youtubeVideo(candidate);
if (!youtube) return null;
const title = markdownLink?.[1]?.trim() || "YouTube video";
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(title)}</a></p></div></div>`;
}
function listLine(line) {
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/);
@@ -336,6 +406,13 @@ export function renderMarkdown(source, lineOffset = 0) {
}
if (inCode) { code.push(line); continue; }
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
if (standaloneMedia) {
closeList();
html += standaloneMedia;
continue;
}
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
if (line.includes("|") && delimiter) {
closeList();
+101 -10
View File
@@ -28,10 +28,11 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
const saveState = document.querySelector("#save-state");
editor.readOnly = true;
@@ -40,12 +41,14 @@ export function startNoteEditor(adapter) {
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let toolbarCollapsed = localStorage.getItem(notePreferenceKey("toolbar-collapsed")) === "on";
const collaborationClientId = typeof crypto.randomUUID === "function"
? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
let pendingPreviewViewport = null;
const editHistory = {
entries: [],
index: -1,
@@ -230,6 +233,7 @@ export function startNoteEditor(adapter) {
lineToggle.checked = info.editor_line_numbers !== false;
previewLineToggle.checked = info.preview_line_numbers === true;
lineLinksToggle.checked = info.line_links === true;
toolbarCollapsed = info.toolbar_collapsed === true;
if (["mono", "system", "serif", "arial", "georgia"].includes(info.font_family)) fontFamily.value = info.font_family;
if (["14", "16", "18", "20", "22"].includes(String(info.font_size))) fontSize.value = String(info.font_size);
}
@@ -325,8 +329,9 @@ export function startNoteEditor(adapter) {
setStatus(null, "Connecting…");
}
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await loadHighlight(); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
async function renderMediaPlayers() { const nodes = preview.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(preview); } catch { } }
function renderParticipantBadges(owners) {
if (!participantBadges) return;
const people = new Map();
@@ -870,15 +875,21 @@ export function startNoteEditor(adapter) {
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
}
function scrollRatio(element) {
function scrollState(element) {
const range = Math.max(0, element.scrollHeight - element.clientHeight);
return range > 0 ? element.scrollTop / range : 0;
const top = Math.max(0, Math.min(range, element.scrollTop));
return {
ratio: range > 0 ? top / range : 0,
atStart: top <= 2,
atEnd: range > 0 && range - top <= 2,
};
}
function editorScrollAnchor() {
const state = scrollState(editor);
return {
sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(),
ratio: scrollRatio(editor),
...state,
};
}
@@ -900,8 +911,9 @@ export function startNoteEditor(adapter) {
}
function previewScrollAnchor() {
const state = scrollState(preview);
const positions = previewLinePositions();
if (!positions.length) return { sourceLine: null, ratio: scrollRatio(preview) };
if (!positions.length) return { sourceLine: null, ...state };
const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
const viewportTop = preview.scrollTop + paddingTop;
let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5);
@@ -912,7 +924,7 @@ export function startNoteEditor(adapter) {
const sourceLine = next
? current.sourceLine + progress * (next.sourceLine - current.sourceLine)
: current.sourceLine;
return { sourceLine, ratio: scrollRatio(preview) };
return { sourceLine, ...state };
}
function activeScrollAnchor(view = renderedView) {
@@ -925,7 +937,9 @@ export function startNoteEditor(adapter) {
}
function scrollEditorToAnchor(anchor) {
if (Number.isFinite(anchor?.sourceLine)) {
if (anchor?.atEnd) setScrollRatio(editor, 1);
else if (anchor?.atStart) setScrollRatio(editor, 0);
else if (Number.isFinite(anchor?.sourceLine)) {
const range = Math.max(0, editor.scrollHeight - editor.clientHeight);
editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight()));
} else setScrollRatio(editor, anchor?.ratio || 0);
@@ -933,6 +947,14 @@ export function startNoteEditor(adapter) {
}
function scrollPreviewToAnchor(anchor) {
if (anchor?.atEnd) {
setScrollRatio(preview, 1);
return;
}
if (anchor?.atStart) {
setScrollRatio(preview, 0);
return;
}
const positions = previewLinePositions();
if (!Number.isFinite(anchor?.sourceLine) || !positions.length) {
setScrollRatio(preview, anchor?.ratio || 0);
@@ -976,7 +998,62 @@ export function startNoteEditor(adapter) {
if (activeView() !== "split") return;
scrollPreviewToAnchor(editorScrollAnchor());
}
function renderNow() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function capturePreviewViewport(target) {
if (!target || !preview.contains(target)) return null;
const sourceLine = Number(target.dataset.sourceLine);
const previewRect = preview.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
return {
sourceLine: Number.isFinite(sourceLine) ? sourceLine : null,
viewportOffset: targetRect.top - previewRect.top,
scrollTop: preview.scrollTop,
focused: document.activeElement === target,
};
}
function restorePreviewViewport(snapshot) {
if (!snapshot) return;
const range = Math.max(0, preview.scrollHeight - preview.clientHeight);
if (Number.isFinite(snapshot.sourceLine)) {
const target = preview.querySelector(`.task-checkbox[data-source-line="${snapshot.sourceLine}"]`)
|| preview.querySelector(`.preview-source-line[data-source-line="${snapshot.sourceLine}"]`);
if (target) {
const previewRect = preview.getBoundingClientRect();
const currentOffset = target.getBoundingClientRect().top - previewRect.top;
preview.scrollTop = Math.max(0, Math.min(range, preview.scrollTop + currentOffset - snapshot.viewportOffset));
if (snapshot.focused) target.focus({ preventScroll: true });
return;
}
}
preview.scrollTop = Math.max(0, Math.min(range, snapshot.scrollTop || 0));
}
function renderNow() {
const previewViewport = pendingPreviewViewport;
pendingPreviewViewport = null;
if (uiState.mode === "markdown") {
preview.classList.remove("preview--raw");
preview.innerHTML = renderMarkdown(editor.value);
scheduleAliasFileRefresh(editor.value);
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
renderMermaid();
renderCodeHighlight();
renderMediaPlayers();
} else {
preview.classList.add("preview--raw");
preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join("");
document.querySelector("#preview-label").textContent = "Text preview";
}
alignPreviewLineNumbers(preview);
document.querySelector("#characters").textContent = `${editor.value.length} characters`;
document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`;
renderGutter();
requestAnimationFrame(() => {
if (previewViewport) restorePreviewViewport(previewViewport);
else syncPreviewScroll();
});
}
const render = createRenderQueue(renderNow);
function queueCollaborativeOperation(operation, ownerReplacements = []) {
if (!collaboration.ready || !canEditDocument()) return false;
@@ -1066,6 +1143,16 @@ export function startNoteEditor(adapter) {
return singlePaneQuery.matches ? compactView : uiState.view;
}
function applyToolbarCollapsed() {
document.body.classList.toggle("toolbar-collapsed", toolbarCollapsed);
if (!toolbarCollapseToggle) return;
const label = toolbarCollapsed ? "Show editor toolbar" : "Hide editor toolbar";
toolbarCollapseToggle.setAttribute("aria-pressed", String(toolbarCollapsed));
toolbarCollapseToggle.setAttribute("aria-label", label);
toolbarCollapseToggle.title = label;
toolbarCollapseToggle.querySelector(".toolbar-collapse-toggle__icon").textContent = toolbarCollapsed ? "⌄" : "⌃";
}
function applyUi({ write = false, replace = false } = {}) {
const view = activeView();
renderedView = view;
@@ -1073,6 +1160,7 @@ export function startNoteEditor(adapter) {
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
applyToolbarCollapsed();
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
@@ -1483,6 +1571,7 @@ export function startNoteEditor(adapter) {
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
});
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUiPreservingScroll({ write: true }); });
toolbarCollapseToggle?.addEventListener("click", () => { toolbarCollapsed = !toolbarCollapsed; localStorage.setItem(notePreferenceKey("toolbar-collapsed"), toolbarCollapsed ? "on" : "off"); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
@@ -1680,6 +1769,7 @@ export function startNoteEditor(adapter) {
editor_line_numbers: lineToggle.checked,
preview_line_numbers: previewLineToggle.checked,
line_links: lineLinksToggle.checked,
toolbar_collapsed: toolbarCollapsed,
font_family: fontFamily.value,
font_size: Number(fontSize.value),
};
@@ -1811,6 +1901,7 @@ export function startNoteEditor(adapter) {
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
pendingPreviewViewport = capturePreviewViewport(checkbox);
lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
+131 -21
View File
@@ -30,9 +30,18 @@ function formatDate(value) {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
}
function aliasCode(filename, label, mimeType) {
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
function isVideo(mimeType) {
return String(mimeType || "").startsWith("video/");
}
function isLikelyVideoFile(file) {
return isVideo(file?.type) || /\.(?:mp4|m4v|mov|webm|ogv)$/i.test(String(file?.name || ""));
}
function aliasCode(filename, label, mimeType, mode = "auto") {
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
let kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
if (isVideo(mimeType) && mode === "player") kind = "video";
return `[${kind}=${filename},${safeLabel}]`;
}
@@ -70,12 +79,84 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
function createVideoInsertDialog() {
const dialog = document.createElement("dialog");
dialog.className = "app-dialog video-insert-dialog";
dialog.innerHTML = `
<div class="video-insert-dialog__panel">
<div class="dialog-heading-row">
<div><p class="eyebrow">Video file</p><h2>How should it be added?</h2></div>
<button type="button" class="icon-button" data-video-choice="cancel" aria-label="Cancel">×</button>
</div>
<p class="dialog-copy" data-video-file-name></p>
<div class="video-choice-actions">
<button type="button" class="action-button action-button--primary" data-video-choice="player">
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
</button>
<button type="button" class="action-button action-button--secondary" data-video-choice="download">
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
</button>
</div>
</div>`;
document.body.append(dialog);
return dialog;
}
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");
const footer = document.querySelector("#footer-files");
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
let videoInsertDialog;
function chooseVideoInsertMode(filename) {
videoInsertDialog ||= createVideoInsertDialog();
videoInsertDialog.querySelector("[data-video-file-name]").textContent = filename;
if (!videoInsertDialog.open) videoInsertDialog.showModal();
return new Promise(resolve => {
let settled = false;
const finish = value => {
if (settled) return;
settled = true;
videoInsertDialog.removeEventListener("click", handleClick);
videoInsertDialog.removeEventListener("cancel", handleCancel);
if (videoInsertDialog.open) videoInsertDialog.close();
resolve(value);
};
const handleClick = event => {
const choice = event.target.closest("[data-video-choice]")?.dataset.videoChoice;
if (!choice) return;
finish(choice === "player" || choice === "download" ? choice : null);
};
const handleCancel = event => {
event.preventDefault();
finish(null);
};
videoInsertDialog.addEventListener("click", handleClick);
videoInsertDialog.addEventListener("cancel", handleCancel);
});
}
function fileActionButtons(file) {
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
const insertButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="player" ${attributes}>Add player</button>
<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>Add download</button>`
: `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="auto" ${attributes}>Add to note</button>`;
const codeButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" ${attributes}>Player code</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" ${attributes}>Download code</button>`
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" ${attributes}>Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" ${attributes}>Markdown</button>`;
const deleteButton = canDelete()
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`
: "";
return `${codeButtons}${insertButtons}${deleteButton}`;
}
async function loadFiles({ open = false } = {}) {
try {
@@ -88,13 +169,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
<div class="file-name">${escapeHtml(file.filename)}</div>
<div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div>
</div>
<div class="file-actions">
<button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>
<button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button>
${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}
</div>
<div class="file-actions">${fileActionButtons(file)}</div>
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
onFilesChanged(files);
@@ -112,7 +187,15 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
return { start: start + text.length, end: start + text.length };
}
async function uploadFile(file, onUploaded) {
function insertVideoPlayer(text, range = null, inputType = "insertText") {
const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length));
const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length));
const prefix = start > 0 && editor.value[start - 1] !== "\n" ? "\n" : "";
const suffix = end < editor.value.length && editor.value[end] !== "\n" ? "\n" : "";
return insertText(`${prefix}${text}${suffix}`, { start, end }, inputType);
}
async function uploadFile(file, onUploaded, insertMode = "auto") {
const uploadToast = createUploadToast(file.name);
let completed = false;
@@ -131,7 +214,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
});
if (completed) return;
completed = true;
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
const text = aliasCode(result.name, file.name, result.mime_type || file.type, insertMode);
await onUploaded(text);
uploadToast.success();
await loadFiles();
@@ -158,20 +241,21 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
input.addEventListener("change", async event => {
let file = event.target.files[0];
if (!file) return;
input.value = "";
if (file.type.startsWith("image/")) {
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
input.value = "";
return;
}
if (!file) { input.value = ""; return; }
if (!file) return;
}
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
if (!insertMode) return;
const range = { start: editor.selectionStart, end: editor.selectionEnd };
input.value = "";
await uploadFile(file, text => insertText(text, range));
await uploadFile(file, text => insertMode === "player" ? insertVideoPlayer(text, range) : insertText(text, range), insertMode);
});
document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
@@ -202,7 +286,10 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
state.replaceEnd = state.cursor;
};
for (const file of files) await uploadFile(file, insertPastedAlias);
for (const file of files) {
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
if (insertMode) await uploadFile(file, insertPastedAlias, insertMode);
}
editor.setSelectionRange(state.cursor, state.cursor);
});
@@ -212,11 +299,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
list.addEventListener("click", async event => {
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
insertText(text);
const mode = addButton.dataset.insertMode;
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode);
if (mode === "player") insertVideoPlayer(text);
else insertText(text);
toast("Added to note");
return;
}
const showButton = event.target.closest("[data-show-file-code]");
if (showButton) {
const panel = showButton.closest(".file-row").querySelector(".file-code");
@@ -228,11 +318,29 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "player") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "player");
} else if (showButton.dataset.showFileCode === "download") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "download");
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
output.value = text;
panel.hidden = false;
output.focus();
output.select();
return;
}
const copyButton = event.target.closest("[data-copy-generated]");
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; }
if (copyButton) {
try {
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
toast("Copied");
} catch (error) {
toast(error.message);
}
return;
}
const deleteButton = event.target.closest("[data-delete-file]");
if (!deleteButton) return;
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
@@ -240,7 +348,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
toast("File deleted");
await loadFiles();
} catch (error) { toast(error.message); }
} catch (error) {
toast(error.message);
}
});
return { loadFiles };
+5 -3
View File
@@ -15,6 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content");
@@ -22,8 +23,9 @@ const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
let pagePassword = "";
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await loadHighlight(); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
async function renderMediaPlayers() { const nodes = content.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(content); } catch { } }
function lockPublicContent(allowTaskUpdates) {
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
@@ -39,7 +41,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" });
return true;
}
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return;
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*/
const config = window.__RUSTPAD_CONFIG__ || {};
const assetVersion = encodeURIComponent(String(config.assetVersion || "dev"));
const promiseCache = new Map();
function localAsset(path) {
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}v=${assetVersion}`;
}
function once(key, factory) {
if (!promiseCache.has(key)) promiseCache.set(key, Promise.resolve().then(factory));
return promiseCache.get(key);
}
function loadClassicScript(path, globalName) {
return once(path, () => new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-rustpad-lib="${globalName}"]`);
if (existing && window[globalName]) {
resolve(window[globalName]);
return;
}
const script = existing || document.createElement("script");
script.src = localAsset(path);
script.async = true;
script.dataset.rustpadLib = globalName;
script.addEventListener("load", () => {
if (window[globalName]) resolve(window[globalName]);
else reject(new Error(`${globalName} did not register a browser global.`));
}, { once: true });
script.addEventListener("error", () => reject(new Error(`Failed to load ${path}.`)), { once: true });
if (!existing) document.head.append(script);
}));
}
export function loadHighlight() {
return loadClassicScript("/assets/libs/highlight/highlight.min.js", "hljs");
}
export function loadMermaid() {
return once("mermaid", async () => {
const module = await import(localAsset("/assets/libs/mermaid/mermaid.esm.min.mjs"));
return module.default;
});
}
export function loadMediaPlayer() {
return once("rustpad-player", () => import(localAsset("/assets/libs/rustpad-player/player.js")));
}