fixes and features

This commit is contained in:
Mateusz Gruszczyński
2026-07-23 13:47:17 +02:00
parent 3ed74b1eac
commit dc6227896b
21 changed files with 831 additions and 82 deletions
+69 -3
View File
@@ -218,8 +218,11 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
.editor-shell textarea { padding-left: 18px; }
.line-toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: .78rem; white-space: nowrap; }
.line-toggle input { width: auto; min-height: auto; margin: 0; accent-color: var(--accent); }
.user-chip { display: inline-flex; align-items: center; gap: 7px; color: #dce2eb; font-size: .78rem; }
.user-chip::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--owner, var(--accent)); }
.user-color-control { position: relative; display: inline-flex; align-items: center; }
.user-chip { display: inline-flex; align-items: center; gap: 7px; padding: 4px 6px; border: 0; border-radius: 7px; background: transparent; color: #dce2eb; font: inherit; font-size: .78rem; }
.user-chip:hover, .user-chip:focus-visible { background: var(--surface-2); outline: none; }
.user-chip__dot { width: 10px; height: 10px; flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--owner, var(--accent)) 72%, white); border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); }
.user-color-picker { position: absolute; top: calc(100% + 4px); left: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; pointer-events: none; }
.dialog-copy { margin: 0 0 4px; color: var(--muted); line-height: 1.5; }
.history-header h2 { margin: 0; }
.history-header p { margin: 4px 0 0; color: var(--muted-2); font-size: .75rem; }
@@ -230,7 +233,7 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
.revision__preview { max-height: 180px; overflow: auto; margin-top: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: #0b0e13; color: #c9d0da; font: .72rem/1.5 ui-monospace, monospace; white-space: pre-wrap; }
.revision button + button { margin-left: 12px; }
.mermaid { overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; }
@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-chip { display: none; } }
@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-color-control { display: none; } }
.markdown-body img { display: block; max-width: 100%; height: auto; margin: 16px auto; border-radius: 10px; }
.markdown-body a { overflow-wrap: anywhere; }
@@ -549,3 +552,66 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
.resource-delete-confirm > p:first-child { margin: 0; }
@media (max-width: 640px) { .resource-main { align-items: flex-start; flex-direction: column; } .resource-actions { width: 100%; } }
.resource-row { align-items: stretch; flex-direction: column; }
/* Stable scrolling and collapsible Markdown sections. */
.editor-shell { width: 100%; max-width: 100%; }
.editor-shell textarea { min-width: 0; max-width: 100%; overscroll-behavior: contain; overflow-anchor: none; }
.line-gutter { position: relative; z-index: 5; flex: none; }
.preview, .editor-shell textarea { scrollbar-gutter: stable; }
.markdown-details { margin: .65em 0; border: 1px solid var(--border); border-radius: 8px; background: rgba(255,255,255,.015); }
.markdown-details > summary { padding: .55em .8em; color: var(--text); font-weight: 650; cursor: pointer; user-select: none; }
.markdown-details[open] > summary { border-bottom: 1px solid var(--border); }
.markdown-details__content { padding: .35em .8em .7em; }
.markdown-details__content > :first-child { margin-top: 0; }
.markdown-details__content > :last-child { margin-bottom: 0; }
/* Full-height editor: keep header/footer visible and scroll only the work area. */
.pad-page { height: 100dvh; min-height: 0; overflow: hidden; display: grid; grid-template-rows: auto minmax(0, 1fr); }
.pad-page .app-header { min-width: 0; }
.pad-page .editor-layout { height: auto; min-height: 0; overflow: hidden; }
.pad-page .editor-panel { min-height: 0; overflow: hidden; }
.pad-page .workspace { min-height: 0; overflow: hidden; }
.pad-page .editor-column,
.pad-page .preview-column,
.pad-page .editor-shell { min-height: 0; overflow: hidden; }
.pad-page .editor-shell textarea,
.pad-page .preview { height: 100%; min-height: 0; overflow: auto; }
.pad-page .editor-footer { position: relative; z-index: 8; flex: none; background: var(--surface); }
@media (max-width: 980px) {
.pad-page .editor-layout { height: auto; }
}
/* A collapsible section has one source-line marker; its rendered Markdown does not repeat line numbers inside. */
.markdown-details__content .preview-source-line::before { display: none; }
.markdown-details__content { overflow: visible; }
.markdown-details__content pre { overflow: auto; }
/* Ephemeral room presence and chat */
.room-details { position: relative; display: inline-block; }
.room-details > summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text); list-style: none; }
.room-details > summary::-webkit-details-marker { display: none; }
.chat-unread { min-width: 17px; height: 17px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 10px; line-height: 17px; text-align: center; }
.room-popover { position: absolute; bottom: calc(100% + 10px); left: 0; z-index: 40; display: grid; grid-template-columns: 150px minmax(280px, 380px); width: min(560px, calc(100vw - 24px)); max-height: min(430px, 70vh); overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: 0 18px 50px rgb(0 0 0 / .38); color: var(--text); }
.room-users { padding: 14px; overflow: auto; border-right: 1px solid var(--border); }
.room-users strong, .room-chat__head strong { display: block; margin-bottom: 8px; font-size: .75rem; }
.room-users ul { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.room-users li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.room-chat { display: grid; grid-template-rows: auto minmax(120px, 1fr) auto; min-height: 280px; }
.room-chat__head { padding: 12px 14px 8px; border-bottom: 1px solid var(--border); }
.room-chat__head strong { margin: 0; }
.room-chat__head span { color: var(--muted-2); font-size: .66rem; }
.chat-messages { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding: 12px 14px; }
.chat-message { margin: 0; overflow-wrap: anywhere; line-height: 1.35; }
.chat-message strong { margin-right: 6px; color: var(--text); }
.chat-message span { color: var(--muted); }
.chat-empty { margin: auto; color: var(--muted-2); }
.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-top: 1px solid var(--border); }
.chat-form input { min-width: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); }
.chat-form button { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; }
@media (max-width: 700px) { .room-popover { grid-template-columns: 1fr; left: auto; right: -120px; } .room-users { max-height: 110px; border-right: 0; border-bottom: 1px solid var(--border); } }
.room-user { display: flex; align-items: center; gap: 8px; min-width: 0; }
.room-user__dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); }
.room-user > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-message a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; }
.chat-message a:hover { filter: brightness(1.15); }
+1
View File
@@ -50,6 +50,7 @@ export function applyFormat(editor, format) {
if (format === "subscript") toggleWrap(editor, "~", "~", "2");
if (format === "superscript") toggleWrap(editor, "^", "^", "2");
if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", "code");
if (format === "details") toggleWrap(editor, "<details>\n<summary>Click me</summary>\n\n", "\n</details>", "Content");
if (format === "table") toggleWrap(editor, "| Column 1 | Column 2 |\n| --- | --- |\n| ", " | value |", "value");
if (format === "footnote") toggleWrap(editor, "", "[^1]\n\n[^1]: Footnote text", "Text with footnote");
if (format === "definition") toggleWrap(editor, "", "\n: Definition", "Term");
+43 -20
View File
@@ -54,7 +54,7 @@ function inline(value) {
return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
}
const attrs = (line, editable = false, prefix = "", suffix = "") => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
function splitTableRow(line) {
@@ -70,7 +70,7 @@ function tableDelimiter(line) {
return cells.map(cell => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left");
}
export function renderMarkdown(source) {
export function renderMarkdown(source, lineOffset = 0) {
let html = "", inCode = false, fence = "", language = "", code = [], codeStart = 0, list = null;
const lines = String(source).split("\n");
const footnotes = new Map();
@@ -93,14 +93,14 @@ export function renderMarkdown(source) {
const closeCode = () => {
const body = escapeHtml(code.join("\n"));
html += language.toLowerCase() === "mermaid"
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + 1}">${body}</div>`
: `<pre${attrs(codeStart)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`
: `<pre${attrs(codeStart, false, "", "", lineOffset)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
code = []; language = ""; fence = "";
};
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const fenceMatch = line.match(/^(```+|~~~+)\s*([^\s]*)\s*$/);
const fenceMatch = line.match(/^\s*(```+|~~~+)\s*([^\s]*)\s*$/);
if (fenceMatch) {
closeList();
if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode();
@@ -114,14 +114,14 @@ export function renderMarkdown(source) {
if (line.includes("|") && delimiter) {
closeList();
const headers = splitTableRow(line);
html += `<div class="table-wrap preview-source-line" data-source-line="${index + 1}"><table><thead><tr>`;
headers.forEach((cell, i) => html += `<th class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
html += `<div class="table-wrap preview-source-line" data-source-line="${index + lineOffset + 1}"><table><thead><tr>`;
headers.forEach((cell, i) => html += `<th class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
html += `</tr></thead><tbody>`;
index += 2;
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
const cells = splitTableRow(lines[index]);
html += `<tr>`;
headers.forEach((_, i) => html += `<td class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
headers.forEach((_, i) => html += `<td class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
html += `</tr>`;
index++;
}
@@ -130,39 +130,62 @@ export function renderMarkdown(source) {
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/);
if (/^<details>\s*$/i.test(line.trim())) {
closeList();
let end = index + 1;
while (end < lines.length && !/^<\/details>\s*$/i.test(lines[end].trim())) end++;
if (end < lines.length) {
const block = lines.slice(index + 1, end);
let summary = "Details";
while (block.length && !block[0].trim()) block.shift();
if (block.length) {
const summaryMatch = block[0].trim().match(/^<summary>([\s\S]*?)<\/summary>$/i);
if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; block.shift(); }
}
while (block.length && !block[0].trim()) block.shift();
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(block.join("\n"), lineOffset + index + 1)}</div></details>`;
index = end;
continue;
}
}
const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/);
const task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/);
const ul = line.match(/^\s*[-*+]\s+(.+)$/);
const ol = line.match(/^\s*\d+\.\s+(.+)$/);
const ul = line.match(/^(\s*)[-*+]\s+(.+)$/);
const ol = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (heading) {
closeList();
const n = heading[1].length;
const id = heading[3] ? ` id="${escapeHtml(heading[3])}"` : "";
const suffix = heading[3] ? ` {#${heading[3]}}` : "";
html += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix)}>${inline(heading[2])}</h${n}>`;
html += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}</h${n}>`;
} else if (task) {
if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; }
const checked = task[2].toLowerCase() === "x";
html += `<li class="preview-source-line task-list-item" data-source-line="${index + 1}"><input type="checkbox" class="task-checkbox" data-source-line="${index + 1}"${checked ? " checked" : ""}><span class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + 1}" data-source-prefix="${escapeHtml(`${task[1]}- [${checked ? "x" : " "}] `)}">${inline(task[3])}</span></li>`;
html += `<li class="preview-source-line task-list-item" data-source-line="${index + lineOffset + 1}"><input type="checkbox" class="task-checkbox" data-source-line="${index + lineOffset + 1}"${checked ? " checked" : ""}><span class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-source-prefix="${escapeHtml(`${task[1]}- [${checked ? "x" : " "}] `)}">${inline(task[3])}</span></li>`;
} else if (ul || ol) {
const type = ul ? "ul" : "ol";
if (list !== type) { closeList(); html += `<${type}>`; list = type; }
html += `<li${attrs(index, true, ul ? "- " : `${(line.match(/^\s*(\d+)\./)||[])[1] || 1}. `)}>${inline((ul || ol)[1])}</li>`;
const match = ul || ol;
const indentWidth = match[1].replace(/\t/g, " ").length;
const prefix = ul ? `${match[1]}- ` : `${match[1]}${(line.match(/^\s*(\d+)\./)||[])[1] || 1}. `;
const indentStyle = indentWidth ? ` style="margin-left:${Math.min(indentWidth, 24) * 0.45}em"` : "";
html += `<li${attrs(index, true, prefix, "", lineOffset)}${indentStyle}>${inline(match[2])}</li>`;
} else {
closeList();
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
if (line.trim() && definition) {
html += `<dl${attrs(index)}><dt>${inline(line)}</dt>`;
html += `<dl${attrs(index, false, "", "", lineOffset)}><dt>${inline(line)}</dt>`;
while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) {
index++;
html += `<dd data-source-line="${index + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
}
html += `</dl>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ")}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, true)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true)}><br></div>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
}
}
+79 -9
View File
@@ -15,14 +15,29 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url
const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3];
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels");
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size");
const 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");
let accessToken=getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
fontSize.value=localStorage.getItem("rustpad:font-size")||"14";
function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
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);return /^#[0-9a-f]{6}$/i.test(parts.color)?parts.color:defaultColorFor(parts.name);}
function currentUserColor(){return localStorage.getItem(storedColorKey(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 toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
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)));}
function appendChatMessage(message){const empty=chatMessages.querySelector(".chat-empty");empty?.remove();const row=document.createElement("p");row.className="chat-message";const author=document.createElement("strong");author.textContent=message.sender;const text=document.createElement("span");appendLinkifiedText(text,message.text);row.append(author,text);chatMessages.append(row);while(chatMessages.children.length>100)chatMessages.firstElementChild.remove();chatMessages.scrollTop=chatMessages.scrollHeight;if(message.sender!==nickname&&!roomDetails.open){unreadChat++;chatUnread.hidden=false;chatUnread.textContent=unreadChat>99?"99+":String(unreadChat);const oldTitle=document.title;if(!document.title.startsWith("● "))document.title=`${oldTitle}`;if(document.hidden&&Notification.permission==="granted")new Notification(`${message.sender} wrote in RustPad`,{body:message.text.slice(0,160),tag:"rustpad-room-chat"});}}
function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textContent="";document.title=document.title.replace(/^● /,"");}
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;}
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
@@ -31,7 +46,7 @@ function renderGutter(){
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount});
owners=owners.slice(0,lineCount);
while(owners.length<lineCount)owners.push(owners.at(-1)||nickname||"");
while(owners.length<lineCount)owners.push(owners.at(-1)||currentOwner()||"");
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.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).join("");
@@ -40,7 +55,7 @@ function renderGutter(){
const owner=owners[i]||"";
if(!owner)return "";
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(owner)}</span>`:"";
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`;
}).join("");
document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);
@@ -65,6 +80,48 @@ function markdownFromPreview(node){
};
return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim();
}
function previewCaretOffset(target){
const selection=window.getSelection();
if(!selection?.rangeCount)return 0;
const range=selection.getRangeAt(0);
if(!target.contains(range.startContainer))return 0;
const prefix=range.cloneRange();
prefix.selectNodeContents(target);
prefix.setEnd(range.startContainer,range.startOffset);
return prefix.toString().length;
}
function placePreviewCaret(target,offset){
const walker=document.createTreeWalker(target,NodeFilter.SHOW_TEXT);
let remaining=Math.max(0,offset),node;
while((node=walker.nextNode())){
if(remaining<=node.nodeValue.length){
const range=document.createRange();range.setStart(node,remaining);range.collapse(true);
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);return;
}
remaining-=node.nodeValue.length;
}
const range=document.createRange();range.selectNodeContents(target);range.collapse(false);
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);
}
function movePreviewCaret(target,direction){
const editables=[...preview.querySelectorAll(".preview-editable")];
const index=editables.indexOf(target),next=editables[index+direction];
if(!next)return false;
const offset=previewCaretOffset(target);next.focus();placePreviewCaret(next,offset);next.scrollIntoView({block:"nearest"});return true;
}
function continueIndentation(event){
if(event.key!=="Enter"||event.shiftKey||event.ctrlKey||event.metaKey||event.altKey)return;
const start=editor.selectionStart,end=editor.selectionEnd;
const lineStart=editor.value.lastIndexOf("\n",start-1)+1;
const current=editor.value.slice(lineStart,start);
const indent=(current.match(/^[ \t]*/)||[""])[0];
if(!indent)return;
event.preventDefault();
editor.setRangeText(`\n${indent}`,start,end,"end");
editor.dispatchEvent(new Event("input",{bubbles:true}));
}
function replaceTableCell(line,index,value){
const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|");
let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1);
@@ -74,7 +131,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";}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 applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,sessionToken:getAuthToken(),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"})}`;},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(),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=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;}
async function loadFiles({open=false}={}){
@@ -86,14 +143,27 @@ async function loadFiles({open=false}={}){
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
}catch(error){toast(error.message);}
}
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}});
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}});
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)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;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;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));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{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;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();});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();}});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});
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates: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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates: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);}});
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
roomDetails.addEventListener("toggle",()=>{if(roomDetails.open){clearUnread();chatInput.focus();if("Notification" in window&&Notification.permission==="default")Notification.requestPermission().catch(()=>{});}});
document.addEventListener("visibilitychange",()=>{if(!document.hidden&&roomDetails.open)clearUnread();});
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);
const replacement=currentOwner();
owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner);
updateCurrentUser();render();
socket?.setColor(userColorPicker.value);
if(socket)socket.update(editor.value,JSON.stringify(owners));
});
editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});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");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";});
+79 -9
View File
@@ -14,14 +14,29 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url
const slug=location.pathname.split("/").filter(Boolean)[1];
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels");
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size");
const 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");
let accessToken=getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
fontSize.value=localStorage.getItem("rustpad:font-size")||"14";
function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
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);return /^#[0-9a-f]{6}$/i.test(parts.color)?parts.color:defaultColorFor(parts.name);}
function currentUserColor(){return localStorage.getItem(storedColorKey(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 toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
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)));}
function appendChatMessage(message){const empty=chatMessages.querySelector(".chat-empty");empty?.remove();const row=document.createElement("p");row.className="chat-message";const author=document.createElement("strong");author.textContent=message.sender;const text=document.createElement("span");appendLinkifiedText(text,message.text);row.append(author,text);chatMessages.append(row);while(chatMessages.children.length>100)chatMessages.firstElementChild.remove();chatMessages.scrollTop=chatMessages.scrollHeight;if(message.sender!==nickname&&!roomDetails.open){unreadChat++;chatUnread.hidden=false;chatUnread.textContent=unreadChat>99?"99+":String(unreadChat);const oldTitle=document.title;if(!document.title.startsWith("● "))document.title=`${oldTitle}`;if(document.hidden&&Notification.permission==="granted")new Notification(`${message.sender} wrote in RustPad`,{body:message.text.slice(0,160),tag:"rustpad-room-chat"});}}
function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textContent="";document.title=document.title.replace(/^● /,"");}
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;}
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
@@ -30,7 +45,7 @@ function renderGutter(){
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount});
owners=owners.slice(0,lineCount);
while(owners.length<lineCount)owners.push(owners.at(-1)||nickname||"");
while(owners.length<lineCount)owners.push(owners.at(-1)||currentOwner()||"");
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.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).join("");
@@ -39,7 +54,7 @@ function renderGutter(){
const owner=owners[i]||"";
if(!owner)return "";
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(owner)}</span>`:"";
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`;
}).join("");
document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);
@@ -64,6 +79,48 @@ function markdownFromPreview(node){
};
return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim();
}
function previewCaretOffset(target){
const selection=window.getSelection();
if(!selection?.rangeCount)return 0;
const range=selection.getRangeAt(0);
if(!target.contains(range.startContainer))return 0;
const prefix=range.cloneRange();
prefix.selectNodeContents(target);
prefix.setEnd(range.startContainer,range.startOffset);
return prefix.toString().length;
}
function placePreviewCaret(target,offset){
const walker=document.createTreeWalker(target,NodeFilter.SHOW_TEXT);
let remaining=Math.max(0,offset),node;
while((node=walker.nextNode())){
if(remaining<=node.nodeValue.length){
const range=document.createRange();range.setStart(node,remaining);range.collapse(true);
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);return;
}
remaining-=node.nodeValue.length;
}
const range=document.createRange();range.selectNodeContents(target);range.collapse(false);
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);
}
function movePreviewCaret(target,direction){
const editables=[...preview.querySelectorAll(".preview-editable")];
const index=editables.indexOf(target),next=editables[index+direction];
if(!next)return false;
const offset=previewCaretOffset(target);next.focus();placePreviewCaret(next,offset);next.scrollIntoView({block:"nearest"});return true;
}
function continueIndentation(event){
if(event.key!=="Enter"||event.shiftKey||event.ctrlKey||event.metaKey||event.altKey)return;
const start=editor.selectionStart,end=editor.selectionEnd;
const lineStart=editor.value.lastIndexOf("\n",start-1)+1;
const current=editor.value.slice(lineStart,start);
const indent=(current.match(/^[ \t]*/)||[""])[0];
if(!indent)return;
event.preventDefault();
editor.setRangeText(`\n${indent}`,start,end,"end");
editor.dispatchEvent(new Event("input",{bubbles:true}));
}
function replaceTableCell(line,index,value){
const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|");
let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1);
@@ -82,15 +139,28 @@ async function loadFiles({open=false}={}){
if(open)document.querySelector("#files-dialog").showModal();
}catch(error){if(open)toast(error.message);}
}
function connect(){socket?.stop();socket=new PadSocket({slug,password,accessToken,nickname,sessionToken:getAuthToken(),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"})}`;},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();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}});
function connect(){socket?.stop();socket=new PadSocket({slug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),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();}}});
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;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));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{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>`;}}
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();}});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});
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates: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 api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates: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);}});
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
roomDetails.addEventListener("toggle",()=>{if(roomDetails.open){clearUnread();chatInput.focus();if("Notification" in window&&Notification.permission==="default")Notification.requestPermission().catch(()=>{});}});
document.addEventListener("visibilitychange",()=>{if(!document.hidden&&roomDetails.open)clearUnread();});
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);
const replacement=currentOwner();
owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner);
updateCurrentUser();render();
socket?.setColor(userColorPicker.value);
if(socket)socket.update(editor.value,JSON.stringify(owners));
});
editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"pad",slug,password})});accessToken=result.access_token;setAccessToken("pad",slug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";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 api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});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 api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});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");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";});
+82 -10
View File
@@ -1,13 +1,85 @@
import { logDebug, logError, logInfo, logWarn } from "./logger.js";
import { logError, logInfo, logWarn } from "./logger.js";
export class NoteSocket {
constructor({ workspaceSlug, noteSlug, password, accessToken, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, accessToken, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,access_token:this.accessToken||null,nickname:this.nickname||null,session_token:this.sessionToken||null}));}); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
class RoomSocket {
constructor(options) {
Object.assign(this, options);
this.socket = null;
this.timer = null;
this.pingTimer = null;
this.closed = false;
this.pendingPings = new Map();
}
get url() { throw new Error("Socket URL not implemented"); }
get kind() { return "room"; }
connect() {
clearTimeout(this.timer);
clearInterval(this.pingTimer);
this.closed = false;
this.onStatus?.("connecting");
this.socket = new WebSocket(this.url);
this.socket.addEventListener("open", () => {
logInfo("websocket.open", { kind: this.kind });
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, session_token: this.sessionToken || null, color: this.color || null });
});
this.socket.addEventListener("message", event => {
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); return; }
if (message.type === "authenticated") {
this.onStatus?.("online");
this.onAuthenticated?.(message);
this.startPing();
return;
}
if (message.type === "document") this.onDocument?.(message);
if (message.type === "presence") this.onPresence?.(message.users || []);
if (message.type === "chat") this.onChat?.(message);
if (message.type === "pong") {
const started = this.pendingPings.get(message.nonce);
if (started !== undefined) {
this.pendingPings.delete(message.nonce);
this.onLatency?.(Math.max(0, Math.round(performance.now() - started)));
}
}
});
this.socket.addEventListener("close", event => {
clearInterval(this.pingTimer);
this.pendingPings.clear();
this.onPresence?.([]);
this.onLatency?.(null);
logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.closed });
if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); }
});
this.socket.addEventListener("error", event => {
logError("websocket.error", event, { kind: this.kind });
this.onError?.("Failed to connect to the WebSocket server");
this.socket.close();
});
}
startPing() {
clearInterval(this.pingTimer);
const ping = () => {
if (this.socket?.readyState !== WebSocket.OPEN) return;
const nonce = Date.now();
this.pendingPings.set(nonce, performance.now());
for (const key of this.pendingPings.keys()) if (key < nonce - 30000) this.pendingPings.delete(key);
this.send({ type: "ping", nonce });
};
ping();
this.pingTimer = setInterval(ping, 10000);
}
send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); }
update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
chat(text) { this.send({ type: "chat", text }); }
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); }
stop() { this.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); this.socket?.close(); }
}
export class PadSocket {
constructor({slug,password,accessToken,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,accessToken,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,access_token:this.accessToken||null,nickname:this.nickname||null,session_token:this.sessionToken||null}));});this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
export class NoteSocket extends RoomSocket {
get kind() { return "note"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; }
}
export class PadSocket extends RoomSocket {
get kind() { return "pad"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; }
}
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></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></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><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></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="details">Collapsible section</button><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details"><summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" hidden></span></summary><div class="room-popover"><section class="room-users"><strong>In this room</strong><ul id="room-users"></ul></section><section class="room-chat"><div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after disconnect</span></div><div id="chat-messages" class="chat-messages" aria-live="polite"></div><form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000" autocomplete="off" placeholder="Write a message…" aria-label="Chat message"><button type="submit">Send</button></form></section></div></details></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></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></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></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></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="details">Collapsible section</button><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details"><summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" hidden></span></summary><div class="room-popover"><section class="room-users"><strong>In this room</strong><ul id="room-users"></ul></section><section class="room-chat"><div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after disconnect</span></div><div id="chat-messages" class="chat-messages" aria-live="polite"></div><form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000" autocomplete="off" placeholder="Write a message…" aria-label="Chat message"><button type="submit">Send</button></form></section></div></details></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>