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
+313 -32
View File
@@ -831,6 +831,93 @@ textarea:focus {
color: var(--text);
}
/* Focus mode removes the toolbar row and leaves only compact floating controls. */
.pad-page .toolbar-collapse-toggle {
display: inline-grid;
flex: 0 0 auto;
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
place-items: center;
overflow: hidden;
border-color: var(--border);
background: var(--surface-inset);
color: var(--muted);
}
.pad-page .toolbar-collapse-toggle:hover {
color: var(--text);
}
.toolbar-collapse-toggle__icon {
display: grid;
width: 100%;
height: 100%;
place-items: center;
font-size: 1.35rem;
font-weight: 800;
line-height: 1;
pointer-events: none;
transform: scaleX(1.3);
transform-origin: center;
}
.pad-page.toolbar-collapsed .editor-panel {
grid-template-rows: minmax(0, 1fr) auto;
}
.pad-page.toolbar-collapsed .editor-toolbar {
position: absolute;
top: 8px;
right: 8px;
z-index: 16;
display: flex;
width: auto;
max-width: calc(100% - 16px);
min-height: 0;
padding: 3px;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 4px;
border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--surface-toolbar) 88%, transparent);
box-shadow: 0 8px 24px var(--shadow-28);
backdrop-filter: blur(10px);
}
.pad-page.toolbar-collapsed .editor-toolbar > :not(.toolbar-collapse-toggle):not(.view-switch) {
display: none !important;
}
.pad-page.toolbar-collapsed .editor-toolbar > .toolbar-collapse-toggle,
.pad-page.toolbar-collapsed .editor-toolbar > .view-switch {
position: static;
display: inline-flex !important;
width: auto;
margin: 0;
}
.pad-page.toolbar-collapsed .editor-toolbar > .toolbar-collapse-toggle {
order: 1;
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
}
.pad-page.toolbar-collapsed .editor-toolbar > .view-switch {
order: 2;
}
.pad-page.toolbar-collapsed .editor-toolbar .view-switch button {
align-items: center;
justify-content: center;
}
.workspace {
display: grid;
min-height: 0;
@@ -2404,6 +2491,69 @@ dialog::backdrop {
}
}
/* Choice shown when a video file is uploaded. */
.video-insert-dialog {
width: min(560px, calc(100vw - 24px));
max-width: 560px;
}
.video-insert-dialog__panel {
display: grid;
gap: 18px;
padding: 24px;
border: 1px solid var(--border-strong);
border-radius: 14px;
background: var(--surface-panel);
color: var(--text);
box-shadow: 0 24px 70px var(--shadow-42);
}
.video-insert-dialog__panel .dialog-heading-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.video-insert-dialog__panel h2,
.video-insert-dialog__panel .eyebrow,
.video-insert-dialog__panel .dialog-copy {
margin: 0;
}
.video-choice-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.video-choice-actions > button {
display: grid;
justify-items: start;
gap: 5px;
min-width: 0;
min-height: 86px;
padding: 14px;
text-align: left;
}
.video-choice-actions span {
color: var(--muted);
font-size: .76rem;
font-weight: 500;
line-height: 1.4;
}
@media (max-width: 600px) {
.video-choice-actions {
grid-template-columns: minmax(0, 1fr);
}
.video-insert-dialog__panel {
padding: 20px 14px 14px;
}
}
.notes-toolbar {
display: flex;
align-items: center;
@@ -3299,6 +3449,39 @@ dialog::backdrop {
grid-column: 1 / -1;
}
#profile-dialog,
#profile-dialog .identity-panel,
#profile-dialog .identity-fields,
#profile-dialog .identity-fields > *,
#profile-dialog .profile-actions,
#profile-dialog .profile-theme-field,
#profile-dialog .theme-options,
#profile-dialog .theme-option {
min-width: 0;
box-sizing: border-box;
}
#profile-dialog .identity-panel {
overflow-x: hidden;
}
#profile-dialog .identity-fields input:not([type="radio"]):not([type="checkbox"]),
#profile-dialog .identity-fields select,
#profile-dialog .identity-fields textarea {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
#profile-dialog .theme-option,
#profile-dialog .theme-option span,
#profile-dialog .theme-option strong,
#profile-dialog .theme-option small,
#profile-dialog .profile-actions button {
overflow-wrap: anywhere;
}
.profile-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3406,21 +3589,42 @@ dialog::backdrop {
}
#profile-dialog {
width: min(440px, calc(100% - 24px));
width: calc(100vw - 16px);
max-width: none;
max-height: calc(100dvh - 16px);
margin: auto;
}
#profile-dialog .identity-panel {
width: 100%;
max-height: calc(100dvh - 16px);
padding: 22px 14px 14px;
gap: 14px;
scrollbar-gutter: auto;
}
#profile-dialog .identity-panel__header {
padding-right: 36px;
}
#profile-dialog .identity-panel,
#profile-dialog .identity-fields,
.profile-actions {
grid-template-columns: 1fr;
#profile-dialog .profile-actions,
#profile-dialog .theme-options {
grid-template-columns: minmax(0, 1fr);
}
#profile-dialog .identity-fields>*,
#profile-dialog .identity-fields > *,
#profile-dialog .profile-actions,
#profile-dialog .form-message {
grid-column: 1;
}
#profile-dialog .profile-actions > button {
min-width: 0;
white-space: normal;
}
.identity-links {
align-items: flex-start;
flex-direction: column;
@@ -3567,6 +3771,10 @@ dialog::backdrop {
padding: 7px 10px;
}
.resource-action-label--short {
display: none;
}
.resource-password-menu {
position: relative;
flex: 0 0 auto;
@@ -3636,16 +3844,6 @@ dialog::backdrop {
background: var(--danger-subtle-bg);
}
@media (max-width:640px) {
.resource-row {
align-items: flex-start;
flex-direction: column
}
.resource-actions {
width: 100%
}
}
.resource-main {
display: flex;
@@ -3695,18 +3893,6 @@ dialog::backdrop {
margin: 0;
}
@media (max-width: 640px) {
.resource-main {
align-items: flex-start;
flex-direction: column;
}
.resource-actions {
width: 100%;
margin-left: 0;
}
}
.resource-row {
align-items: stretch;
flex-direction: column;
@@ -4457,6 +4643,81 @@ dialog::backdrop {
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
@media (max-width: 640px) {
#resources-dialog {
width: calc(100% - 12px);
}
#resources-dialog .resources-panel {
padding: 14px;
}
#resources-dialog .resources-panel__header {
padding-right: 34px;
}
.resource-row {
padding: 10px;
}
.resource-main {
align-items: stretch;
justify-content: flex-start;
flex-direction: column;
gap: 10px;
}
.resource-copy {
width: 100%;
flex: 0 1 auto;
}
.resource-title-line {
display: block;
}
.resource-title-line a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
margin-left: 0;
gap: 4px;
}
.resource-actions > button,
.resource-actions > .resource-password-menu,
.resource-password-menu > summary {
width: 100%;
min-width: 0;
}
.resource-actions .compact-button {
min-width: 0;
padding: 7px 4px;
gap: 3px;
font-size: .72rem;
}
.resource-action-label--full {
display: none;
}
.resource-action-label--short {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 640px) {
.share-dialog,
@@ -5211,7 +5472,7 @@ dialog::backdrop {
.pad-page .editor-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
min-height: 0;
padding: 8px;
@@ -5235,6 +5496,8 @@ dialog::backdrop {
.pad-page #mode-toggle {
display: inline-flex;
grid-column: 2;
grid-row: 1;
width: auto;
min-width: 0;
align-self: center;
@@ -5242,6 +5505,12 @@ dialog::backdrop {
white-space: nowrap;
}
.pad-page .toolbar-collapse-toggle {
grid-column: 3;
grid-row: 1;
align-self: center;
}
.pad-page .toolbar-group:has(>.emoji-picker[open]),
.pad-page .toolbar-group:has(>.markdown-more[open]) {
overflow: visible;
@@ -5251,6 +5520,8 @@ dialog::backdrop {
position: sticky;
right: 0;
z-index: 2;
grid-column: 4;
grid-row: 1;
width: max-content;
max-width: 100%;
align-self: center;
@@ -5298,7 +5569,7 @@ dialog::backdrop {
}
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
@@ -6741,7 +7012,7 @@ dialog::backdrop {
@media (max-width: 760px) and (orientation: portrait) {
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto auto auto;
width: 100%;
max-width: 100vw;
}
@@ -6948,11 +7219,16 @@ dialog::backdrop {
grid-row: 1;
}
.pad-page .view-switch {
.pad-page .toolbar-collapse-toggle {
grid-column: 2;
grid-row: 1;
}
.pad-page .view-switch {
grid-column: 3;
grid-row: 1;
}
.pad-page .mobile-upload-button {
display: inline-flex;
grid-column: 1 / -1;
@@ -6989,8 +7265,13 @@ dialog::backdrop {
grid-row: 1;
}
.pad-page .view-switch {
.pad-page .toolbar-collapse-toggle {
grid-column: 3;
grid-row: 1;
}
}
.pad-page .view-switch {
grid-column: 4;
grid-row: 1;
}
}
+5 -2
View File
@@ -117,7 +117,7 @@
</details>
</div>
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
title="Upload an image or file"><span aria-hidden="true"></span> Upload file</button>
title="Upload a file"><span aria-hidden="true"></span> Upload</button>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
@@ -131,7 +131,7 @@
<option value="20">20</option>
<option value="22">22</option>
</select></label></div><button id="upload-button"
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
class="toolbar-action" title="Upload a file">Upload</button><input id="file-input" type="file" hidden><label
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
@@ -141,6 +141,9 @@
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
class="control-label-full">Markdown</span><span class="control-label-short"
aria-hidden="true">M</span></button>
<button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button"
aria-pressed="false" aria-label="Hide editor toolbar" title="Hide editor toolbar"><span
class="toolbar-collapse-toggle__icon" aria-hidden="true"></span></button>
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active"
aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
class="control-label-short" aria-hidden="true">E</span></button><button data-view="split"
+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")));
}
+18
View File
@@ -0,0 +1,18 @@
# Browser libraries
Only `rustpad-player/` belongs to the repository. Mermaid and Highlight.js are generated directories and must not be committed.
Run:
```bash
python3 scripts/update_browser_libs.py
```
The updater resolves the current stable npm versions, verifies package integrity, copies the required browser assets and licenses, and atomically replaces:
- `static/libs/mermaid/`
- `static/libs/highlight/`
`./dev.sh` runs the updater before local Cargo development. Docker downloads the same libraries in the `browser-libs` build stage. The application lazy-loads them only when matching content is rendered.
Project-owned media helpers remain in `static/libs/rustpad-player/`.
+56
View File
@@ -0,0 +1,56 @@
.rustpad-media {
width: min(100%, 960px);
margin: 18px auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: #000;
box-shadow: 0 12px 30px color-mix(in srgb, #000 22%, transparent);
}
.rustpad-media__player,
.rustpad-media__player iframe,
.rustpad-media__player video,
video.rustpad-media__player {
display: block;
width: 100%;
max-width: 100%;
}
.rustpad-media__player[data-player-kind="youtube"] {
position: relative;
aspect-ratio: 16 / 9;
}
.rustpad-media__player[data-player-kind="youtube"] iframe {
position: absolute;
inset: 0;
height: 100%;
border: 0;
}
video.rustpad-media__player,
.rustpad-media__player video {
max-height: min(72vh, 760px);
background: #000;
}
.rustpad-media__fallback {
margin: 0;
padding: 10px 12px;
border-top: 1px solid color-mix(in srgb, white 18%, transparent);
background: var(--surface-deep);
color: var(--muted);
font-size: .78rem;
}
.rustpad-media__fallback a {
color: var(--accent-soft);
}
@media (max-width: 600px) {
.rustpad-media {
margin-block: 14px;
border-radius: 9px;
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
* RustPad media player helpers.
* Project-owned code: keeps native video playback and YouTube embeds responsive.
*/
const initialized = new WeakSet();
function createYouTubeFrame(node) {
const videoId = String(node.dataset.videoId || "").trim();
if (!/^[A-Za-z0-9_-]{6,20}$/.test(videoId)) return;
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}?rel=0&modestbranding=1&playsinline=1`;
iframe.title = node.dataset.playerTitle || "YouTube video";
iframe.loading = "lazy";
iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
iframe.referrerPolicy = "strict-origin-when-cross-origin";
iframe.allowFullscreen = true;
node.replaceChildren(iframe);
}
function initializeNativeVideo(video) {
video.controls = true;
video.playsInline = true;
video.preload = video.preload || "metadata";
const fallback = video.closest(".rustpad-media")?.querySelector(".rustpad-media__fallback");
if (!fallback) return;
const revealFallback = () => { fallback.hidden = false; };
video.addEventListener("error", revealFallback, { once: true });
video.querySelectorAll("source").forEach(source => source.addEventListener("error", revealFallback, { once: true }));
}
export function hydrateMediaPlayers(root = document) {
root.querySelectorAll("[data-rustpad-player]").forEach(node => {
if (initialized.has(node)) return;
initialized.add(node);
if (node.dataset.playerKind === "youtube") createYouTubeFrame(node);
else if (node instanceof HTMLVideoElement) initializeNativeVideo(node);
});
}
export function destroyMediaPlayers(root = document) {
root.querySelectorAll('[data-rustpad-player][data-player-kind="youtube"] iframe').forEach(frame => {
frame.src = "about:blank";
});
}