fixes and features
This commit is contained in:
@@ -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
@@ -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
@@ -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})`;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
@@ -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})`;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
@@ -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)}`; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user