This commit is contained in:
Mateusz Gruszczyński
2026-07-25 17:54:34 +02:00
parent 2d8bb24fcc
commit 4c4c15495d
8 changed files with 89 additions and 24 deletions
Generated
+1 -1
View File
@@ -2433,7 +2433,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.0.20" version = "0.0.22"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.0.20" version = "0.0.22"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+21
View File
@@ -4093,3 +4093,24 @@ dialog::backdrop {
.authorship-layer { left: 42px; } .authorship-layer { left: 42px; }
.hide-editor-line-numbers .authorship-layer { left: 0; } .hide-editor-line-numbers .authorship-layer { left: 0; }
} }
/* Multiple authors can contribute to one line. */
.owner-label-group {
position: absolute;
right: 8px;
display: flex;
max-width: min(420px, 70%);
gap: 4px;
overflow: hidden;
transform: translateY(-50%);
white-space: nowrap;
}
.owner-label-group .owner-label {
position: static;
right: auto;
flex: 0 1 auto;
min-width: 0;
max-width: 150px;
transform: none;
}
+41
View File
@@ -91,6 +91,47 @@ export function replaceAuthorshipOwner(model, matcher, replacement, contentLengt
}; };
} }
export function lineAuthors(content, model) {
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
return starts.map((start, index) => {
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
const totals = new Map();
for (const span of model?.spans || []) {
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (overlap) totals.set(span.owner, (totals.get(span.owner) || 0) + overlap);
}
return [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([owner]) => owner);
});
}
export function mapSelectionThroughEdit(previousText, nextText, start, end = start) {
if (previousText === nextText) return { start, end };
let prefix = 0;
const shared = Math.min(previousText.length, nextText.length);
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
let oldSuffix = previousText.length;
let newSuffix = nextText.length;
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
oldSuffix--;
newSuffix--;
}
const insertedLength = newSuffix - prefix;
const delta = insertedLength - (oldSuffix - prefix);
const map = position => {
if (position < prefix) return position;
if (position > oldSuffix) return position + delta;
if (position === prefix && oldSuffix === prefix) return prefix + insertedLength;
return prefix + insertedLength;
};
return {
start: Math.max(0, Math.min(nextText.length, map(start))),
end: Math.max(0, Math.min(nextText.length, map(end))),
};
}
export function lineOwners(content, model) { export function lineOwners(content, model) {
const starts = [0]; const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1); for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
+11 -9
View File
@@ -2,7 +2,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics(); installGlobalDiagnostics();
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { applyAuthorshipEdit, lineOwners, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship"; import { applyAuthorshipEdit, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { bindEmojiPicker } from "@rustpad/emoji-picker";
@@ -37,6 +37,8 @@ function currentUserColor() { return localStorage.getItem(storedColorKey(nicknam
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; } function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; } function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); } function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
function sessionHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() { info = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { headers: sessionHeaders() }); return info; }
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } } function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } }
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); } function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
@@ -50,17 +52,17 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p
function renderGutter() { function renderGutter() {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount }); const lines = Array.from({ length: lineCount });
const owners = lineOwners(editor.value, authorship); const authorsByLine = lineAuthors(editor.value, authorship);
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`; gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join(""); gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.innerHTML = lines.map((_, i) => { ownerLabels.innerHTML = lines.map((_, i) => {
const owner = owners[i] || ""; const authors = authorsByLine[i] || [];
if (!owner) return ""; if (!authors.length) return "";
const top = paddingTop + i * lineHeight - editor.scrollTop; const top = paddingTop + i * lineHeight - editor.scrollTop;
const label = owner !== owners[i - 1] ? `<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>` : ""; const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`; return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></span><span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join(""); }).join("");
renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
@@ -142,7 +144,7 @@ function replaceTableCell(line, index, value) {
} }
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; 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}" contenteditable="true" spellcheck="true">${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(); } function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; 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}" contenteditable="true" spellcheck="true">${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(); }
function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); } function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); } function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
function connect() { socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, 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 => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); 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 => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } function connect() { socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, 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 => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); 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 => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; } function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
@@ -156,9 +158,9 @@ async function loadFiles({ open = false } = {}) {
if (open && !document.querySelector("#files-dialog").open) document.querySelector("#files-dialog").showModal(); if (open && !document.querySelector("#files-dialog").open) document.querySelector("#files-dialog").showModal();
} catch (error) { toast(error.message); } } catch (error) { toast(error.message); }
} }
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; 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(); }); }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function initialize() { try { info = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {} }); document.title = `${info.title} · ${info.workspace_title}`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); document.querySelector("#delete-note").hidden = info.note_protected; if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } } async function initialize() { try { await loadNoteInfo(); document.title = `${info.title} · ${info.workspace_title}`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); document.querySelector("#delete-note").hidden = info.note_protected; if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } }
document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); 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(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); 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(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; 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 })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; 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 })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
+12 -9
View File
@@ -2,7 +2,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics(); installGlobalDiagnostics();
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { applyAuthorshipEdit, lineOwners, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship"; import { applyAuthorshipEdit, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
@@ -33,6 +33,8 @@ function currentUserColor() { return localStorage.getItem(storedColorKey(nicknam
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; } function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; } function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); } function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
function sessionHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadPadInfo() { info = await api(`/api/pads/${encodeURIComponent(slug)}`, { headers: sessionHeaders() }); return info; }
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } } function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } }
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); } function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
@@ -46,18 +48,19 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p
function renderGutter() { function renderGutter() {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount }); const lines = Array.from({ length: lineCount });
const owners = lineOwners(editor.value, authorship); const authorsByLine = lineAuthors(editor.value, authorship);
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`; gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join(""); gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.innerHTML = lines.map((_, i) => { ownerLabels.innerHTML = lines.map((_, i) => {
const owner = owners[i] || ""; const authors = authorsByLine[i] || [];
if (!owner) return ""; if (!authors.length) return "";
const top = paddingTop + i * lineHeight - editor.scrollTop; const top = paddingTop + i * lineHeight - editor.scrollTop;
const label = owner !== owners[i - 1] ? `<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>` : ""; const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`; return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></span><span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join(""); }).join("");
renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
document.body.classList.toggle("hide-line-numbers", !lineToggle.checked); document.body.classList.toggle("hide-line-numbers", !lineToggle.checked);
} }
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
@@ -138,7 +141,7 @@ function replaceTableCell(line, index, value) {
} }
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; 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}" contenteditable="true" spellcheck="true">${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(); } function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; 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}" contenteditable="true" spellcheck="true">${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(); }
function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); } function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); } function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
async function loadFiles({ open = false } = {}) { async function loadFiles({ open = false } = {}) {
try { try {
@@ -150,9 +153,9 @@ async function loadFiles({ open = false } = {}) {
} catch (error) { if (open) toast(error.message); } } catch (error) { if (open) toast(error.message); }
} }
function connect() { socket?.stop(); socket = new PadSocket({ slug, 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 => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); 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 => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } function connect() { socket?.stop(); socket = new PadSocket({ slug, 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 => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); 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 => { document.querySelector("#password-error").textContent = m; 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; identityDialog.close(); updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); await loadPadInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function initialize() { try { info = await api(`/api/pads/${encodeURIComponent(slug)}`); document.title = `${info.title} · RustPad`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } } async function initialize() { try { await loadPadInfo(); document.title = `${info.title} · RustPad`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } }
document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); 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(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); 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(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; 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 })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; 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 })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
+1 -2
View File
@@ -173,8 +173,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="identity-dialog"> <dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" <form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
class="modal-close" type="button" aria-label="Close dialog">×</button>
<h2>What should we call you?</h2> <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 <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 id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
+1 -2
View File
@@ -161,8 +161,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="identity-dialog"> <dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" <form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
class="modal-close" type="button" aria-label="Close dialog">×</button>
<h2>What should we call you?</h2> <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 <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 id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required