new functions

This commit is contained in:
Mateusz Gruszczyński
2026-07-27 12:36:00 +02:00
parent 63999f0878
commit 79298b5d17
15 changed files with 1064 additions and 218 deletions
+304 -57
View File
@@ -263,7 +263,7 @@ textarea:focus {
.inline-button {
width: auto;
margin-top: 18px;
padding: 0 16px;
padding: 0 18px;
}
.app-header {
@@ -445,6 +445,11 @@ textarea:focus {
display: none;
}
.workspace > .editor-column,
.workspace > .preview-column {
transition: opacity .18s ease, transform .18s ease;
}
.editor-column,
.preview-column {
display: grid;
@@ -823,43 +828,6 @@ dialog::backdrop {
display: none;
}
.editor-toolbar {
overflow-x: auto;
flex-wrap: nowrap;
}
.toolbar-fill {
display: none;
}
.workspace.view-split {
grid-template-columns: 1fr;
}
.workspace.view-split .preview-column {
display: none;
}
.view-switch button[data-view="split"] {
display: none;
}
.preview-column {
border-left: 0;
}
textarea,
.preview {
padding: 18px;
}
.editor-footer {
align-items: flex-start;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 7px 12px;
}
}
@media (max-width: 480px) {
@@ -961,10 +929,6 @@ dialog::backdrop {
gap: 8px;
}
.inline-button {
width: auto;
padding-inline: 18px;
}
.error-card {
width: min(560px, 100%);
@@ -1117,6 +1081,7 @@ dialog::backdrop {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.user-chip {
@@ -3623,17 +3588,6 @@ dialog::backdrop {
padding: 7px 11px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 800px) {
.share-dialog {
@@ -4078,7 +4032,9 @@ dialog::backdrop {
white-space: nowrap;
}
.document-owner-badge[hidden] { display: none; }
.document-owner-badge[hidden] {
display: none;
}
/* Per-character authorship overlay. The textarea remains the editable surface. */
.authorship-layer {
@@ -4109,11 +4065,15 @@ dialog::backdrop {
background: transparent;
}
.hide-editor-line-numbers .authorship-layer { left: 0; }
.hide-editor-line-numbers .authorship-layer {
left: 0;
}
@media (max-width: 720px) {
.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. */
@@ -4210,7 +4170,10 @@ dialog::backdrop {
height: auto;
}
.footer-access{font-weight:600;color:var(--muted,#9ca3af)}
.footer-access {
color: var(--muted, #9ca3af);
font-weight: 600;
}
.profile-suggestion {
margin: 0;
@@ -4222,3 +4185,287 @@ dialog::backdrop {
cursor: default;
opacity: .78;
}
.header-navigation {
display: flex;
align-items: center;
margin-left: auto;
}
.header-menu-toggle {
display: none;
}
.header-user-control {
flex: 0 0 auto;
margin-left: auto;
}
.profile-color-field input[type="color"] {
width: 100%;
min-height: 42px;
padding: 4px;
cursor: pointer;
}
/* Responsive note editor -------------------------------------------------- */
@media (max-width: 1499px) {
.pad-page {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100dvh;
overflow: hidden;
}
.pad-page .app-header {
align-items: stretch;
gap: 10px;
padding: 10px 12px;
}
.pad-page .app-header__main {
min-width: 0;
flex: 1 1 auto;
}
.pad-page .document-heading {
flex: 1;
}
.pad-page .header-user-control {
margin-left: auto;
}
.pad-page .header-navigation {
position: relative;
flex: 0 0 auto;
margin-left: 0;
}
.pad-page .header-menu-toggle {
display: inline-flex;
width: 42px;
min-width: 42px;
height: 42px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
padding: 0;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
}
.pad-page .header-menu-toggle span {
width: 18px;
height: 2px;
border-radius: 2px;
background: currentColor;
transition: transform .18s ease, opacity .18s ease;
}
.pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.pad-page .header-actions {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
display: none;
width: min(320px, calc(100vw - 20px));
max-height: min(70dvh, 520px);
align-items: stretch;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: #0d1015;
box-shadow: 0 16px 40px rgb(0 0 0 / 45%);
}
.pad-page .header-actions.is-open {
display: flex;
}
.pad-page .header-actions > * {
width: 100%;
flex: 0 0 auto;
}
.pad-page .header-actions .secondary-button,
.pad-page .header-actions .user-chip {
justify-content: flex-start;
}
.pad-page .header-actions .public-task-toggle {
min-height: 40px;
padding: 8px 10px;
}
.pad-page .editor-layout {
flex: 1 1 auto;
width: 100%;
height: auto;
min-height: 320px;
}
.pad-page .editor-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-height: 0;
padding: 8px;
overflow: visible;
}
.pad-page .toolbar-group {
min-width: 0;
overflow-x: auto;
padding-right: 0;
border-right: 0;
scrollbar-width: thin;
}
.pad-page .editor-controls,
.pad-page .toolbar-action,
.pad-page .line-toggle,
.pad-page .toolbar-fill,
.pad-page #mode-toggle {
display: none;
}
.pad-page .view-switch {
position: sticky;
right: 0;
z-index: 2;
align-self: center;
background: #0d1015;
}
.pad-page .view-switch button[data-view="split"] {
display: none;
}
.pad-page .workspace,
.pad-page .workspace.view-split {
grid-template-columns: minmax(0, 1fr);
}
.pad-page .workspace.view-edit .editor-column,
.pad-page .workspace.view-preview .preview-column {
display: grid;
opacity: 1;
transform: translateX(0);
}
.pad-page .workspace.view-edit .preview-column,
.pad-page .workspace.view-preview .editor-column {
display: none;
opacity: 0;
}
.pad-page .preview-column {
border-left: 0;
}
.pad-page textarea,
.pad-page .preview,
.pad-page .authorship-layer {
padding: 16px;
}
.pad-page .editor-footer {
align-items: center;
gap: 8px;
min-height: 42px;
padding: 6px 10px;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: thin;
}
.pad-page .footer-left,
.pad-page .footer-right {
flex: 0 0 auto;
}
}
@media (max-width: 520px) {
.pad-page .app-header {
padding-inline: 10px;
}
.pad-page .brand {
max-width: 40vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pad-page .document-heading h1 {
font-size: .9rem;
}
.pad-page .user-chip__name {
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pad-page .editor-toolbar {
padding: 6px;
}
.pad-page .editor-toolbar button,
.pad-page .editor-toolbar select {
min-height: 32px;
padding-inline: 8px;
}
.pad-page textarea,
.pad-page .preview,
.pad-page .authorship-layer {
padding: 12px;
}
}
@media (min-width: 1500px) {
.pad-page .workspace.view-split {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
/* Per-note author color override */
.use-global-color {
display: inline-grid;
width: 1.75rem;
height: 1.75rem;
padding: 0;
place-items: center;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--surface-2);
color: var(--muted);
cursor: pointer;
}
.use-global-color:hover {
border-color: var(--accent);
color: var(--text);
}
+1
View File
@@ -133,6 +133,7 @@
<label data-directory-profile-field>Organization<input id="profile-directory-organization" readonly></label>
<p id="profile-nickname-suggestion" data-directory-profile-field class="profile-suggestion"></p>
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
<label class="profile-color-field">Editor color<input id="profile-color" type="color" aria-label="Choose your editor color"></label>
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320" placeholder="Leave empty to keep current"></label>
<label data-local-profile-field>New password<input id="profile-new-password" type="password" minlength="8" maxlength="128" placeholder="Leave empty to keep current"></label>
+4 -1
View File
@@ -105,6 +105,7 @@ const resourcesDialog = document.querySelector("#resources-dialog");
const resourcesList = document.querySelector("#resources-list");
const resourcesError = document.querySelector("#resources-error");
const profileDialog = document.querySelector("#profile-dialog");
const profileForm = document.querySelector("#profile-form");
let currentSession = null;
@@ -269,6 +270,8 @@ if (identityDialog) {
document.querySelector("#footer-profile")?.addEventListener("click", () => {
document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
const profileColor = document.querySelector("#profile-color");
profileColor.value = currentSession?.editor_color || "#7c6cff";
document.querySelector("#profile-current-email").value = currentSession?.email || "";
document.querySelector("#profile-email").value = "";
document.querySelector("#profile-new-password").value = "";
@@ -294,7 +297,7 @@ if (identityDialog) {
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
profileForm?.addEventListener("submit", async event => {
event.preventDefault(); const message=document.querySelector("#profile-message"); message.textContent=""; message.classList.remove("success", "error");
try { const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),new_email:currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null),new_password:currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null),password:currentSession?.directory_managed?"":document.querySelector("#profile-password").value})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); }
try { const selectedColor=document.querySelector("#profile-color").value; const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),editor_color:selectedColor,new_email:currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null),new_password:currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null),password:currentSession?.directory_managed?"":document.querySelector("#profile-password").value})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; currentSession.editor_color=result.editor_color; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); }
});
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value;
+4
View File
@@ -15,6 +15,8 @@ export function createPadAdapter() {
addressSelector: "#pad-url",
title: info => `${info.title} · RustPad`,
loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }),
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
fileEndpoints: {
list: `${base}/files`,
upload: `${base}/files`,
@@ -53,6 +55,8 @@ export function createWorkspaceNoteAdapter() {
addressSelector: "#note-url",
title: info => `${info.title} · ${info.workspace_title}`,
loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }),
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
fileEndpoints: {
list: `${base}/files`,
upload: `${base}/files`,
+98 -16
View File
@@ -16,25 +16,42 @@ 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");
let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "";
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
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";
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
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) }; }
function ownerName(owner) { return ownerParts(owner).name; }
function colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); }
function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
const guestColorKey = `rustpad:guest-color:${adapter.access.kind}:${adapter.access.key}`;
function readGuestColor() { return sessionStorage.getItem(guestColorKey) || ""; }
function writeGuestColor(color) { if (color) sessionStorage.setItem(guestColorKey, color); else sessionStorage.removeItem(guestColorKey); }
function globalUserColor() { return globalColor || ""; }
function noteUserColor() { return noteColor || ""; }
function currentUserColor() { return noteUserColor() || globalUserColor(); }
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); 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 = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; useGlobalColorButton.hidden = !overridden; }
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 = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); return info; }
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
globalColor = info.global_color || ""; noteColor = info.note_color || "";
if (getAuthToken()) {
const colors = await adapter.loadColor(accountHeaders());
globalColor = colors.global_color || ""; noteColor = colors.note_color || "";
} else {
noteColor = readGuestColor();
}
updateCurrentUser(); 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 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))); }
@@ -169,7 +186,28 @@ export function startNoteEditor(adapter) {
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
}
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}" 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 activeView() {
return compactLayoutQuery.matches ? compactView : uiState.view;
}
function applyUi({ write = false, replace = false } = {}) {
const view = activeView();
editorWorkspace.className = `workspace view-${view} editor-workspace-font-${fontFamily.value}`;
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
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 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; }
const { loadFiles } = bindNoteFiles({
@@ -204,7 +242,40 @@ export function startNoteEditor(adapter) {
}
}
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(); alignPreviewLineNumbers(preview); }); 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(button => button.addEventListener("click", () => {
if (compactLayoutQuery.matches) {
compactView = button.dataset.view === "preview" ? "preview" : "edit";
applyUi();
return;
}
uiState = { ...uiState, view: button.dataset.view };
applyUi({ write: true });
}));
compactLayoutQuery.addEventListener("change", () => applyUi());
const headerMenuToggle = document.querySelector("#header-menu-toggle");
const headerActions = document.querySelector("#header-actions");
const setHeaderMenuOpen = open => {
headerActions.classList.toggle("is-open", open);
headerMenuToggle.setAttribute("aria-expanded", String(open));
headerMenuToggle.setAttribute("aria-label", open ? "Close navigation menu" : "Open navigation menu");
};
headerMenuToggle.addEventListener("click", event => {
event.stopPropagation();
setHeaderMenuOpen(!headerActions.classList.contains("is-open"));
});
headerActions.addEventListener("click", event => {
if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false);
});
document.addEventListener("click", event => {
if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") setHeaderMenuOpen(false);
});
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(); }); 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 });
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } });
@@ -212,26 +283,37 @@ export function startNoteEditor(adapter) {
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
currentUser.addEventListener("click", () => userColorPicker.click());
userColorPicker.addEventListener("input", () => {
localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
userColorPicker.addEventListener("change", async () => {
noteColor = userColorPicker.value;
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
} else {
writeGuestColor(noteColor);
toast("Color saved for this tab");
}
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(userColorPicker.value);
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
});
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
window.addEventListener("storage", event => {
if (event.key !== storedColorKey(nickname)) return;
useGlobalColorButton.addEventListener("click", async () => {
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
}
noteColor = "";
if (!getAuthToken()) writeGuestColor("");
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(currentUserColor() || null);
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
toast("Global profile color restored");
});
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
+17 -10
View File
@@ -20,16 +20,23 @@
<p id="note-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><span class="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="Choose your color"></span><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><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 on Page</label><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>
<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><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 on Page</label><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">
+17 -10
View File
@@ -19,16 +19,23 @@
<p id="pad-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><span class="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="Choose your color"></span><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><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 on Page</label><button id="files-button"
class="secondary-button">Files</button><button id="history-button"
class="secondary-button">History</button></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><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 on Page</label><button id="files-button"
class="secondary-button">Files</button><button id="history-button"
class="secondary-button">History</button></div>
</div>
</header>
<main class="editor-layout">
<section class="editor-panel">