new finctions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 00:12:48 +02:00
parent 9ca055de5f
commit 2274cf57c9
31 changed files with 1348 additions and 505 deletions
+109 -12
View File
@@ -300,6 +300,18 @@ textarea:focus {
min-width: 0;
}
.document-heading--copy {
border-radius: 6px;
cursor: copy;
outline: none;
}
.document-heading--copy:hover,
.document-heading--copy:focus-visible {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 10%, transparent);
}
.document-heading h1 {
overflow: hidden;
margin: 0;
@@ -4532,11 +4544,22 @@ dialog::backdrop {
gap: 5px;
max-width: calc(100vw - 20px);
padding: 5px;
isolation: isolate;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 999px;
background: rgba(17, 21, 28, .88);
background: transparent;
box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
}
.pad-page .mobile-editor-bubble::before {
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
background: rgba(17, 21, 28, .88);
backdrop-filter: blur(10px);
content: "";
pointer-events: none;
}
.mobile-editor-bubble>button,
@@ -5059,6 +5082,12 @@ dialog::backdrop {
flex-wrap: wrap;
}
.authorship-controls button:disabled,
.authorship-controls input:disabled+.switch-control__track {
cursor: not-allowed;
opacity: .55;
}
.switch-control {
display: inline-flex;
align-items: center;
@@ -5107,17 +5136,6 @@ dialog::backdrop {
background: var(--accent);
}
.editor-settings-save {
min-height: 22px;
height: 22px;
padding: 0 8px;
font-size: 11px;
}
.editor-settings-save:disabled {
opacity: .45;
cursor: not-allowed;
}
.share-link-row .share-link-info {
margin-top: 8px;
@@ -5320,6 +5338,7 @@ dialog::backdrop {
width: auto;
}
}
/* Optional links to exact editor lines. */
.line-number-button {
display: block;
@@ -5357,3 +5376,81 @@ dialog::backdrop {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--text);
}
/* Personal editor preferences in the compact mobile action bar. */
@media (max-width: 1499px) {
.mobile-editor-options {
position: relative;
flex: 0 0 auto;
}
.mobile-editor-options>summary {
display: inline-grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .07);
color: var(--text);
cursor: pointer;
list-style: none;
}
.mobile-editor-options>summary::-webkit-details-marker {
display: none;
}
.mobile-editor-options[open]>summary {
background: color-mix(in srgb, var(--accent) 24%, rgba(255, 255, 255, .07));
}
.mobile-editor-options__panel {
position: fixed;
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
left: auto;
display: grid;
width: min(360px, calc(100vw - 24px));
max-height: calc(100dvh - 88px);
gap: 5px;
padding: 12px;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-strong, #11161e);
box-shadow: 0 16px 40px rgb(0 0 0 / 45%);
}
.mobile-editor-options__panel>label:not(.mobile-option-check) {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
align-items: center;
gap: 10px;
color: var(--muted);
font-size: .78rem;
}
.mobile-editor-options__panel select {
width: 100%;
min-height: 34px;
}
.mobile-option-check {
display: flex;
min-height: 24px;
align-items: center;
gap: 7px;
color: var(--text);
font-size: .8rem;
line-height: 1.2;
}
.mobile-option-check input {
width: 17px;
height: 17px;
margin: 0;
}
}
+40 -18
View File
@@ -5,19 +5,20 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__PAD_TITLE__ · RustPad</title>
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a class="brand home-brand" href="/">RustPad</a><span
class="header-divider"></span>
<div class="document-heading">
<h1 id="pad-title">__PAD_TITLE__</h1>
<p id="pad-url" class="document-url"></p>
<div class="app-header__main"><a id="resource-parent-link" class="brand __PARENT_CLASS__"
href="__PARENT_URL__">__PARENT_TITLE__</a><span class="header-divider"></span>
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
title="Copy this link" aria-label="Copy this link">
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
<p id="document-url" class="document-url"></p>
</div>
</div>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
@@ -43,7 +44,8 @@
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
</details><button id="files-button" class="secondary-button">Files</button><button id="history-button"
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
class="secondary-button">History</button>
</div>
</div>
@@ -119,14 +121,12 @@
<div class="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
title="Show or hide author coloring"><input id="authorship-colors-toggle"
type="checkbox" checked><span class="switch-control__track"
type="checkbox" checked disabled><span class="switch-control__track"
aria-hidden="true"></span><span id="authorship-colors-label">Colors
on</span></label>
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button
type="button" data-authorship-mode="simple" class="active">Simple</button><button
type="button" data-authorship-mode="full">Full</button></div><button
id="save-editor-settings" class="secondary-button compact-button editor-settings-save"
type="button">Save</button>
type="button" data-authorship-mode="simple" class="active" disabled>Simple</button><button
type="button" data-authorship-mode="full" disabled>Full</button></div>
</div>
</div>
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
@@ -193,8 +193,7 @@
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span><kbd>Alt+Enter</kbd><span>New line while
editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__
</div>
</div>
</dialog>
@@ -219,9 +218,9 @@
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / LDAP Username<input
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com or name.second-name"></label><label>Password<input
placeholder="you@example.com"></label><label>Password<input
id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
type="submit">Log in and continue</button>
@@ -235,7 +234,7 @@
</dialog>
<dialog id="password-dialog">
<form id="password-form" class="dialog-panel">
<h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required
<h2>Protected __PROTECTED_RESOURCE_LABEL__</h2><input id="open-password" type="password" autocomplete="current-password" required
placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
class="dialog-link" href="/">Cancel</a>
@@ -245,6 +244,29 @@
<button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions"
aria-label="Move quick actions">⋮⋮</button>
<button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button>
<details id="mobile-editor-options" class="mobile-editor-options">
<summary title="Editor options" aria-label="Open editor options"></summary>
<div class="mobile-editor-options__panel">
<label>Font<select id="mobile-font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
<option value="serif">Serif</option>
<option value="arial">Arial</option>
<option value="georgia">Georgia</option>
</select></label>
<label>Size<select id="mobile-font-size">
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select></label>
<label class="mobile-option-check"><input id="mobile-line-numbers-toggle" type="checkbox"> Editor lines</label>
<label class="mobile-option-check"><input id="mobile-preview-line-numbers-toggle" type="checkbox"> Preview lines</label>
<label class="mobile-option-check"><input id="mobile-compact-toggle" type="checkbox"> Compact view</label>
<label class="mobile-option-check"><input id="mobile-line-links-toggle" type="checkbox"> Line links</label>
</div>
</details>
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
+29
View File
@@ -28,6 +28,25 @@ function safeUrl(value) {
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
export function setMarkdownFiles(files) {
markdownFiles = new Map((Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
.map(file => [String(file.filename), {
url: String(file.url),
mimeType: String(file.mime_type || ""),
}]));
}
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)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
}
function inline(value) {
const tokens = [];
@@ -39,6 +58,16 @@ 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) => {
const file = markdownFiles.get(filename);
if (!file) return match;
const text = String(label || filename).trim() || filename;
if (kind.toLowerCase() === "file") {
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>`);
}
if (!file.mimeType.startsWith("image/")) return match;
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
});
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false"${titleAttr}>`);
+2 -2
View File
@@ -21,7 +21,7 @@ export function createPadAdapter() {
return {
access: { kind: "pad", key: slug },
addressSelector: "#pad-url",
addressSelector: "#document-url",
title: info => `${info.title} · RustPad`,
loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }),
@@ -62,7 +62,7 @@ export function createWorkspaceNoteAdapter() {
return {
access: { kind: "workspace", key: workspaceSlug },
addressSelector: "#note-url",
addressSelector: "#document-url",
title: info => `${info.title} · ${info.workspace_title}`,
loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }),
+158 -25
View File
@@ -15,7 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
@@ -27,24 +27,69 @@ export function startNoteEditor(adapter) {
const modeToggle = document.querySelector("#mode-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"), 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");
let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
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"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`;
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
lineLinksToggle.checked = localStorage.getItem("rustpad:line-links") === "on";
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
let refreshFilesForAliases = () => { };
let aliasRefreshTimer = 0;
let lastUnresolvedAliasKey = "";
let markdownFileSignature = "";
function updateMarkdownFiles(files, { rerender = false } = {}) {
const normalized = (Array.isArray(files) ? files : []).map(file => ({
filename: String(file?.filename || ""),
url: String(file?.url || ""),
mime_type: String(file?.mime_type || ""),
})).sort((left, right) => left.filename.localeCompare(right.filename));
const nextSignature = JSON.stringify(normalized);
const changed = nextSignature !== markdownFileSignature;
markdownFileSignature = nextSignature;
setMarkdownFiles(normalized);
if (rerender && changed) render();
}
function scheduleAliasFileRefresh(content) {
const key = unresolvedMarkdownFileAliases(content).sort().join("\u0000");
if (!key) {
lastUnresolvedAliasKey = "";
return;
}
if (key === lastUnresolvedAliasKey) return;
lastUnresolvedAliasKey = key;
clearTimeout(aliasRefreshTimer);
aliasRefreshTimer = window.setTimeout(() => refreshFilesForAliases(), 200);
}
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem(notePreferenceKey("line-numbers")) !== "off";
previewLineToggle.checked = localStorage.getItem(notePreferenceKey("preview-line-numbers")) === "on";
compactToggle.checked = localStorage.getItem(notePreferenceKey("compact")) !== "off";
lineLinksToggle.checked = localStorage.getItem(notePreferenceKey("line-links")) === "on";
fontFamily.value = localStorage.getItem(notePreferenceKey("font-family")) || "mono";
fontSize.value = localStorage.getItem(notePreferenceKey("font-size")) || "14";
authorshipColorsToggle.checked = authorshipColorsEnabled;
function syncMobileEditorControls() {
if (mobileFontFamily) mobileFontFamily.value = fontFamily.value;
if (mobileFontSize) mobileFontSize.value = fontSize.value;
if (mobileLineToggle) mobileLineToggle.checked = lineToggle.checked;
if (mobilePreviewLineToggle) mobilePreviewLineToggle.checked = previewLineToggle.checked;
if (mobileCompactToggle) mobileCompactToggle.checked = compactToggle.checked;
if (mobileLineLinksToggle) mobileLineLinksToggle.checked = lineLinksToggle.checked;
}
syncMobileEditorControls();
function updateAuthorshipControls() {
const canManage = info?.can_manage_authorship === true;
authorshipColorsToggle.checked = authorshipColorsEnabled;
authorshipColorsToggle.disabled = !canManage;
authorshipColorsLabel.textContent = authorshipColorsEnabled ? "Colors on" : "Colors off";
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode));
document.querySelectorAll("[data-authorship-mode]").forEach(button => {
button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode);
button.disabled = !canManage;
});
const controls = document.querySelector(".authorship-controls");
if (controls) controls.title = canManage ? "Global authorship settings" : "Only the owner can change authorship settings";
}
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
@@ -67,7 +112,13 @@ export function startNoteEditor(adapter) {
}, replacement, contentLength);
}
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
function sessionHeaders() {
const userToken = getAuthToken();
const resourceToken = accessToken || userToken;
const headers = resourceToken ? { Authorization: `Bearer ${resourceToken}` } : {};
if (userToken && userToken !== resourceToken) headers["X-RustPad-User-Token"] = userToken;
return headers;
}
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
@@ -78,10 +129,19 @@ export function startNoteEditor(adapter) {
} else {
noteColor = readGuestColor();
}
updateMarkdownFiles(info.files || []);
if (info.personal_editor_settings) {
compactToggle.checked = info.compact_view !== false;
lineToggle.checked = info.editor_line_numbers !== false;
previewLineToggle.checked = info.preview_line_numbers === true;
lineLinksToggle.checked = info.line_links === 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);
}
authorshipMode = info.authorship_mode === "full" ? "full" : "simple";
authorshipColorsEnabled = info.colors_enabled !== false;
updateAuthorshipControls();
if (saveEditorSettingsButton) saveEditorSettingsButton.disabled = !info.can_save_editor_settings;
syncMobileEditorControls();
updateCurrentUser(); return info;
}
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
@@ -202,10 +262,17 @@ export function startNoteEditor(adapter) {
if (tag === "code") return { open: "`", close: "`", atomic: null };
if (tag === "sub") return { open: "~", close: "~", atomic: null };
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
const fileAlias = current.getAttribute("data-file-alias");
const fileName = current.getAttribute("data-file-name");
if (tag === "a" && fileAlias === "file" && fileName) return { open: `[file=${fileName},`, close: "]", atomic: null };
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
if (tag === "img") {
const src = current.getAttribute("src") || "";
const alt = current.getAttribute("alt") || "";
if (fileAlias === "image" && fileName) {
const safeAlt = alt.replace(/\]/g, ")").replace(/[\r\n]+/g, " ");
return { open: "", close: "", atomic: `[image=${fileName},${safeAlt}]` };
}
const src = current.getAttribute("src") || "";
const title = current.getAttribute("title");
return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})` };
}
@@ -478,7 +545,7 @@ export function startNoteEditor(adapter) {
const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0;
preview.scrollTop = ratio * previewRange;
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(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 · editable"; } 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 render() { 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 · editable"; } 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 activeView() {
return singlePaneQuery.matches ? compactView : uiState.view;
}
@@ -549,9 +616,11 @@ export function startNoteEditor(adapter) {
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
endpoints: adapter.fileEndpoints,
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
});
refreshFilesForAliases = () => loadFiles();
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken(); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() {
try {
@@ -579,7 +648,7 @@ export function startNoteEditor(adapter) {
return;
}
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken();
await loadNoteInfo();
document.title = adapter.title(info);
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
@@ -631,7 +700,24 @@ export function startNoteEditor(adapter) {
compactLayoutQuery.addEventListener("change", event => {
if (!event.matches) setHeaderMenuOpen(false);
});
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-links", lineLinksToggle.checked ? "on" : "off"); renderGutter(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: 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(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
const mirrorMobileControl = (mobileControl, desktopControl) => mobileControl?.addEventListener("change", () => {
if (desktopControl instanceof HTMLInputElement && desktopControl.type === "checkbox") desktopControl.checked = mobileControl.checked;
else desktopControl.value = mobileControl.value;
desktopControl.dispatchEvent(new Event("change", { bubbles: true }));
});
mirrorMobileControl(mobileFontFamily, fontFamily);
mirrorMobileControl(mobileFontSize, fontSize);
mirrorMobileControl(mobileLineToggle, lineToggle);
mirrorMobileControl(mobilePreviewLineToggle, previewLineToggle);
mirrorMobileControl(mobileCompactToggle, compactToggle);
mirrorMobileControl(mobileLineLinksToggle, lineLinksToggle);
document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click());
const compactBubbleQuery = matchMedia("(max-width: 1499px)");
const roomPopover = roomDetails.querySelector(".room-popover");
@@ -788,27 +874,66 @@ export function startNoteEditor(adapter) {
});
}
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
if (!info?.can_manage_authorship) return;
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
updateAuthorshipControls();
syncMobileEditorControls();
renderGutter();
scheduleEditorSettingsSave({ authorship: true });
}));
authorshipColorsToggle?.addEventListener("change", () => {
if (!info?.can_manage_authorship) return;
authorshipColorsEnabled = authorshipColorsToggle.checked;
updateAuthorshipControls();
syncMobileEditorControls();
renderGutter();
scheduleEditorSettingsSave({ authorship: true });
});
saveEditorSettingsButton?.addEventListener("click", async () => {
if (!info?.can_save_editor_settings) return;
saveEditorSettingsButton.disabled = true;
function personalEditorSettingsPayload() {
return {
compact_view: compactToggle.checked,
editor_line_numbers: lineToggle.checked,
preview_line_numbers: previewLineToggle.checked,
line_links: lineLinksToggle.checked,
font_family: fontFamily.value,
font_size: Number(fontSize.value),
};
}
function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) {
if (personal) pendingPersonalSettingsSave = true;
if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true;
if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return;
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
async function flushEditorSettingsSave() {
if (editorSettingsSaveInFlight || !info?.can_save_editor_settings) return;
const savePersonal = pendingPersonalSettingsSave;
const saveAuthorship = pendingAuthorshipSettingsSave && info.can_manage_authorship;
if (!savePersonal && !saveAuthorship) return;
pendingPersonalSettingsSave = false;
pendingAuthorshipSettingsSave = false;
editorSettingsSaveInFlight = true;
const settings = savePersonal ? personalEditorSettingsPayload() : {};
if (saveAuthorship) {
settings.authorship_mode = authorshipMode;
settings.colors_enabled = authorshipColorsEnabled;
}
try {
await adapter.saveEditorSettings(sessionHeaders(), { authorship_mode: authorshipMode, colors_enabled: authorshipColorsEnabled });
toast("Editor settings saved for everyone");
await adapter.saveEditorSettings(sessionHeaders(), settings);
if (savePersonal) info.personal_editor_settings = true;
} catch (error) {
toast(error.message);
} finally {
saveEditorSettingsButton.disabled = !info?.can_save_editor_settings;
editorSettingsSaveInFlight = false;
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
}
});
}
window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("rustpad:urlchange", updateAddressLabel);
@@ -826,9 +951,17 @@ export function startNoteEditor(adapter) {
toast(error.message);
}
});
document.querySelector("#copy-link").addEventListener("click", async () => {
async function copyCurrentLink() {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); }
}
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
const documentLinkCopy = document.querySelector("#document-link-copy");
documentLinkCopy?.addEventListener("click", copyCurrentLink);
documentLinkCopy?.addEventListener("keydown", event => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copyCurrentLink();
});
let pendingPreviewFormatRange = null;
+19 -7
View File
@@ -31,7 +31,17 @@ function formatDate(value) {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast }) {
function aliasCode(filename, label, mimeType) {
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
return `[${kind}=${filename},${safeLabel}]`;
}
function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`;
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast, onFilesChanged = () => {} }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
@@ -50,12 +60,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
</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-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);
if (open && !dialog.open) dialog.showModal();
} catch (error) {
if (open) toast(error.message);
@@ -95,8 +107,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
});
if (completed) return;
completed = true;
const fileUrl = safeAppUrl(result.url);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
uploadToast.success();
@@ -117,8 +128,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
list.addEventListener("click", async event => {
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const relative = safeAppUrl(addButton.dataset.url);
const text = addButton.dataset.mime?.startsWith("image/") ? `![${addButton.dataset.name}](${relative})` : `[${addButton.dataset.name}](${relative})`;
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
toast("Added to note");
@@ -130,9 +140,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
const output = panel.querySelector("textarea");
const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href;
let text = absolute;
if (showButton.dataset.showFileCode === "markdown") {
if (showButton.dataset.showFileCode === "alias") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
const relative = safeAppUrl(showButton.dataset.url);
text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${relative})` : `[${showButton.dataset.name}](${relative})`;
text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
}
+2 -2
View File
@@ -12,7 +12,7 @@ installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
const token = location.pathname.split("/").filter(Boolean)[1];
@@ -39,7 +39,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`; 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()]); 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;
-259
View File
@@ -1,259 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__NOTE_TITLE__ · RustPad</title>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a id="workspace-link" class="brand"
href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="note-title">__NOTE_TITLE__</h1>
<p id="note-url" class="document-url"></p>
</div>
</div>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
type="color" aria-label="Override color for this note"><button id="use-global-color"
class="use-global-color" type="button" title="Use global profile color"
aria-label="Use global profile color" hidden></button></span>
<div class="header-navigation">
<button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
aria-controls="header-actions" aria-label="Open navigation menu">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
link</button><button id="publish-page" class="secondary-button">Page</button>
<details class="page-settings">
<summary class="secondary-button">Page settings</summary>
<div class="page-settings-menu"><label class="public-task-toggle"
title="Enable or disable the published page"><input id="public-page-enabled"
type="checkbox"> Enable Page</label><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks</label><label
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
class="secondary-button">History</button>
</div>
</div>
</header>
<main class="editor-layout">
<section class="editor-panel">
<div class="editor-toolbar">
<div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button
data-format="italic" title="Italic"><em>I</em></button><button data-format="strike"
title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button
data-format="heading2">H2</button><button data-format="heading3">H3</button><button
data-format="heading4">H4</button><button data-format="bullet">• List</button><button
data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button
data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button
data-format="quote">Quote</button><button data-format="link">Link</button>
<details id="emoji-picker" class="emoji-picker">
<summary title="Insert emoji" aria-label="Insert emoji">😀 Emoji</summary>
<div class="emoji-picker-panel">
<input id="emoji-search" class="emoji-search" type="search" placeholder="Search emoji…"
autocomplete="off" aria-label="Search emoji">
<div id="emoji-categories" class="emoji-categories" aria-label="Emoji categories"></div>
<div id="emoji-grid" class="emoji-grid" role="listbox" aria-label="Emoji"></div>
<p id="emoji-empty" class="emoji-empty" hidden>No emoji found.</p>
</div>
</details>
<details class="markdown-more">
<summary title="Extended Markdown">More</summary>
<div class="markdown-more-menu"><button type="button" data-format="details">Collapsible
section</button><button type="button" data-format="toc">Table of
contents</button><button type="button" data-format="alert-success">Success
alert</button><button type="button" data-format="alert-info">Info
alert</button><button type="button" data-format="alert-warning">Warning
alert</button><button type="button" data-format="alert-danger">Danger
alert</button><button type="button" data-format="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</button><button
type="button" data-format="codeblock-lines">Code block with line
numbers</button><button type="button" data-format="mermaid">Mermaid
diagram</button><button type="button" data-format="table">Table</button><button
type="button" data-format="footnote">Footnote</button><button type="button"
data-format="definition">Definition</button><button type="button"
data-format="highlight">Highlight</button><button type="button"
data-format="subscript">Subscript</button><button type="button"
data-format="superscript">Superscript</button><button type="button"
data-format="horizontal-rule">Horizontal rule</button></div>
</details>
</div>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
<option value="serif">Serif</option>
<option value="arial">Arial</option>
<option value="georgia">Georgia</option>
</select></label><label>Size<select id="font-size">
<option value="14" selected>14</option>
<option value="16">16</option>
<option value="18">18</option>
<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="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>
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
Line links</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
aria-pressed="true">Markdown</button>
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
title="Show or hide author coloring"><input id="authorship-colors-toggle"
type="checkbox" checked><span class="switch-control__track"
aria-hidden="true"></span><span id="authorship-colors-label">Colors
on</span></label>
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button
type="button" data-authorship-mode="simple" class="active">Simple</button><button
type="button" data-authorship-mode="full">Full</button></div><button
id="save-editor-settings" class="secondary-button compact-button editor-settings-save"
type="button">Save</button>
</div>
</div>
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
</div>
</div>
<div class="preview-column">
<div id="preview-label" class="column-label">Markdown preview</div>
<article id="preview" class="preview markdown-body"></article>
</div>
</div>
<footer class="editor-footer">
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
words</span><span class="footer-connection-block"> · <span class="footer-status status"><span
id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
<details id="room-details" class="room-details">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
<div class="room-popover">
<section class="room-users"><strong>In this room</strong>
<ul id="room-users"></ul>
</section>
<section class="room-chat">
<div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after
disconnect</span></div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000"
autocomplete="off" placeholder="Write a message…"
aria-label="Chat message"><button type="submit">Send</button></form>
</section>
</div>
</details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <span id="access-level"
class="footer-access">Access: checking…</span> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span>
</footer>
</section>
<aside id="history-panel" class="history-panel" aria-hidden="true">
<div class="history-header">
<div>
<h2>Change history</h2>
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
<dialog id="shortcuts-dialog">
<div class="dialog-panel shortcuts-panel">
<div class="files-head">
<div>
<h2>Keyboard shortcuts</h2>
<p>Use Ctrl on Windows/Linux or Cmd on macOS.</p>
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>
</div>
</div>
</dialog>
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
<div class="image-editor-panel files-panel">
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
</div>
<div id="files-list" class="files-list"></div>
</div>
</dialog>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
<h2>What should we call you?</h2>
<p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input
id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
placeholder="Name or nickname">
<div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
password?</button><button id="auth-back" class="text-button" type="button">Back to
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
account</button></div>
</section>
<p id="identity-error" class="form-message error" role="alert"></p>
</form>
</dialog>
<dialog id="password-dialog">
<form id="password-form" class="dialog-panel">
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"
required placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
class="dialog-link" href="/">Cancel</a>
</form>
</dialog>
<div id="mobile-editor-bubble" class="mobile-editor-bubble" aria-label="Quick editor actions">
<button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions"
aria-label="Move quick actions">⋮⋮</button>
<button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button>
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span
id="mobile-status-dot" class="status__dot"></span><span
id="mobile-status-text">Connecting…</span></span>
</div>
<div id="toast" class="toast"></div>
</body>
</html>