new functions and js v=
This commit is contained in:
+158
-158
@@ -1,199 +1,199 @@
|
||||
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
|
||||
installGlobalDiagnostics();
|
||||
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
|
||||
import { prepareImageFile } from "./image-upload.js";
|
||||
import { getNickname, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||
import { bindIdentityDialog } from "./auth-ui.js";
|
||||
import { prepareImageFile } from "@rustpad/image-upload";
|
||||
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||
import { bindIdentityDialog } from "@rustpad/auth-ui";
|
||||
import { PadSocket } from "@rustpad/socket";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
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 roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread");
|
||||
let unreadChat=0;
|
||||
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker");
|
||||
const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("pad",slug,shareToken);
|
||||
let accessToken=shareToken||getAuthToken()||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 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(/^● /,"");}
|
||||
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 roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
|
||||
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken("pad", slug, shareToken);
|
||||
let accessToken = shareToken || getAuthToken() || 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 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>'));}}
|
||||
async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>{const lines=node.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(node);return;}const language=[...node.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});node.classList.add("hljs");});}catch{}}
|
||||
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)||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("");
|
||||
ownerLabels.style.setProperty("--editor-line-height",`${lineHeight}px`);
|
||||
ownerLabels.innerHTML=lines.map((_,i)=>{
|
||||
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(ownerName(owner))}</span>`:"";
|
||||
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>')); } }
|
||||
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
|
||||
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) || 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("");
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.innerHTML = lines.map((_, i) => {
|
||||
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(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);
|
||||
document.body.classList.toggle("hide-line-numbers", !lineToggle.checked);
|
||||
}
|
||||
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});}
|
||||
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
|
||||
|
||||
function markdownFromPreview(node){
|
||||
const walk=current=>{
|
||||
if(current.nodeType===Node.TEXT_NODE)return current.nodeValue||"";
|
||||
if(current.nodeType!==Node.ELEMENT_NODE)return "";
|
||||
const tag=current.tagName.toLowerCase(),body=[...current.childNodes].map(walk).join("");
|
||||
if(tag==="strong"||tag==="b")return `**${body}**`;
|
||||
if(tag==="em"||tag==="i")return `*${body}*`;
|
||||
if(tag==="s"||tag==="del")return `~~${body}~~`;
|
||||
if(tag==="mark")return `==${body}==`;
|
||||
if(tag==="code")return "`"+body+"`";
|
||||
if(tag==="sub")return `~${body}~`;
|
||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
||||
if(tag==="img"){
|
||||
const src=current.getAttribute("src")||"";
|
||||
const alt=current.getAttribute("alt")||"";
|
||||
const title=current.getAttribute("title");
|
||||
return `}"`:""})`;
|
||||
function markdownFromPreview(node) {
|
||||
const walk = current => {
|
||||
if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
|
||||
if (tag === "strong" || tag === "b") return `**${body}**`;
|
||||
if (tag === "em" || tag === "i") return `*${body}*`;
|
||||
if (tag === "s" || tag === "del") return `~~${body}~~`;
|
||||
if (tag === "mark") return `==${body}==`;
|
||||
if (tag === "code") return "`" + body + "`";
|
||||
if (tag === "sub") return `~${body}~`;
|
||||
if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
|
||||
if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
|
||||
if (tag === "img") {
|
||||
const src = current.getAttribute("src") || "";
|
||||
const alt = current.getAttribute("alt") || "";
|
||||
const title = current.getAttribute("title");
|
||||
return `}"` : ""})`;
|
||||
}
|
||||
if(tag==="br")return " ";
|
||||
if (tag === "br") return " ";
|
||||
return body;
|
||||
};
|
||||
return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim();
|
||||
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();
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
remaining -= node.nodeValue.length;
|
||||
}
|
||||
const range=document.createRange();range.selectNodeContents(target);range.collapse(false);
|
||||
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);
|
||||
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 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;
|
||||
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}));
|
||||
editor.setRangeText(`\n${indent}`, start, end, "end");
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function formatBytes(bytes){const value=Math.max(0,Number(bytes)||0),units=["B","KB","MB","GB","TB"];let size=value,index=0;while(size>=1024&&index<units.length-1){size/=1024;index++;}return `${index===0?Math.round(size):size.toFixed(size>=10?1:2)} ${units[index]}`;}
|
||||
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
|
||||
|
||||
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);
|
||||
const cells=body.split("|").map(cell=>cell.trim());while(cells.length<=index)cells.push("");cells[index]=value.replace(/\|/g,"|");
|
||||
return `${leading?"| ":""}${cells.join(" | ")}${trailing?" |":""}`;
|
||||
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);
|
||||
const cells = body.split("|").map(cell => cell.trim()); while (cells.length <= index) cells.push(""); cells[index] = value.replace(/\|/g, "|");
|
||||
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
|
||||
}
|
||||
function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}alignPreviewLineNumbers(preview);document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
|
||||
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
|
||||
function 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 render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
|
||||
function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
|
||||
function 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(); }
|
||||
|
||||
async function loadFiles({open=false}={}){
|
||||
try{
|
||||
const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
||||
const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0);
|
||||
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`;
|
||||
document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached?"":" detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button>${info?.can_delete_files?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="dialog-copy">No files uploaded.</p>';
|
||||
if(open)document.querySelector("#files-dialog").showModal();
|
||||
}catch(error){if(open)toast(error.message);}
|
||||
async function loadFiles({ open = false } = {}) {
|
||||
try {
|
||||
const files = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "PUT", body: JSON.stringify({ access_token: accessToken || null }) });
|
||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||
document.querySelector("#footer-files").textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
||||
document.querySelector("#files-list").innerHTML = files.length ? files.map(file => `<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button>${info?.can_delete_files ? `<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
||||
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,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;}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>`;}}
|
||||
function connect() { socket?.stop(); socket = new PadSocket({ slug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||
async function initialize() { try { info = await api(`/api/pads/${encodeURIComponent(slug)}`); document.title = `${info.title} · RustPad`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } }
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
|
||||
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();return;}if(event.key==="ArrowUp"||event.key==="ArrowDown"){if(movePreviewCaret(target,event.key==="ArrowUp"?-1:1))event.preventDefault();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
|
||||
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);}});
|
||||
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();
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
|
||||
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
|
||||
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); } });
|
||||
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));
|
||||
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})`;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="";});
|
||||
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})`; 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 = ""; });
|
||||
|
||||
document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true}));
|
||||
document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true}));
|
||||
document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close());
|
||||
document.querySelector("#files-list").addEventListener("click",async event=>{
|
||||
const showButton=event.target.closest("[data-show-file-code]");
|
||||
if(showButton){
|
||||
const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea");
|
||||
const absolute=new URL(showButton.dataset.url,location.origin).href;
|
||||
let text=absolute;
|
||||
if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?``:`[${showButton.dataset.name}](${absolute})`;
|
||||
if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`<img src="${absolute}" alt="${showButton.dataset.name}">`:`<a href="${absolute}">${showButton.dataset.name}</a>`;
|
||||
output.value=text;panel.hidden=false;output.focus();output.select();return;
|
||||
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
||||
document.querySelector("#footer-files").addEventListener("click", () => loadFiles({ open: true }));
|
||||
document.querySelector("#close-files").addEventListener("click", () => document.querySelector("#files-dialog").close());
|
||||
document.querySelector("#files-list").addEventListener("click", async event => {
|
||||
const showButton = event.target.closest("[data-show-file-code]");
|
||||
if (showButton) {
|
||||
const row = showButton.closest(".file-row"), panel = row.querySelector(".file-code"), output = panel.querySelector("textarea");
|
||||
const absolute = new URL(showButton.dataset.url, location.origin).href;
|
||||
let text = absolute;
|
||||
if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `` : `[${showButton.dataset.name}](${absolute})`;
|
||||
if (showButton.dataset.showFileCode === "html") text = (showButton.dataset.mime || "").startsWith("image/") ? `<img src="${absolute}" alt="${showButton.dataset.name}">` : `<a href="${absolute}">${showButton.dataset.name}</a>`;
|
||||
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
|
||||
}
|
||||
const copyButton=event.target.closest("[data-copy-generated]");
|
||||
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
|
||||
const deleteButton=event.target.closest("[data-delete-file]");
|
||||
if(deleteButton){
|
||||
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return;
|
||||
try{await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
||||
const copyButton = event.target.closest("[data-copy-generated]");
|
||||
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; }
|
||||
const deleteButton = event.target.closest("[data-delete-file]");
|
||||
if (deleteButton) {
|
||||
if (!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`)) return;
|
||||
try { await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`, { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: accessToken || null }) }); toast("File deleted"); await loadFiles(); } catch (error) { toast(error.message); } return;
|
||||
}
|
||||
});
|
||||
initialize();
|
||||
|
||||
Reference in New Issue
Block a user