new functions and fixes
This commit is contained in:
+18
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
Reference in New Issue
Block a user