line multi authors

This commit is contained in:
Mateusz Gruszczyński
2026-07-25 17:33:01 +02:00
parent 0a02a0880e
commit 2d8bb24fcc
7 changed files with 197 additions and 20 deletions
+1
View File
@@ -5,6 +5,7 @@ use axum::{
const MODULES: &[&str] = &[
"api",
"authorship",
"auth-ui",
"clipboard",
"editor-format",
+36
View File
@@ -4057,3 +4057,39 @@ dialog::backdrop {
grid-template-columns: 1fr;
}
}
/* Per-character authorship overlay. The textarea remains the editable surface. */
.authorship-layer {
position: absolute;
z-index: 1;
inset: 0 0 0 48px;
overflow: hidden;
padding: 24px;
color: transparent;
font: inherit;
font-size: var(--editor-font-size, 14px);
line-height: inherit;
white-space: pre;
tab-size: 4;
pointer-events: none;
}
.authorship-fragment {
border-radius: 2px;
background: color-mix(in srgb, var(--owner) 18%, transparent);
box-shadow: inset 0 -2px color-mix(in srgb, var(--owner) 72%, transparent);
color: transparent;
}
.editor-shell textarea {
position: relative;
z-index: 3;
background: transparent;
}
.hide-editor-line-numbers .authorship-layer { left: 0; }
@media (max-width: 720px) {
.authorship-layer { left: 42px; }
.hide-editor-line-numbers .authorship-layer { left: 0; }
}
+139
View File
@@ -0,0 +1,139 @@
const VERSION = 2;
function normalize(spans, length) {
const sorted = (Array.isArray(spans) ? spans : [])
.map(span => ({
start: Math.max(0, Math.min(length, Number(span?.start) || 0)),
end: Math.max(0, Math.min(length, Number(span?.end) || 0)),
owner: String(span?.owner || ""),
}))
.filter(span => span.owner && span.end > span.start)
.sort((a, b) => a.start - b.start || a.end - b.end);
const result = [];
for (const span of sorted) {
const previous = result.at(-1);
if (previous && previous.owner === span.owner && span.start <= previous.end) {
previous.end = Math.max(previous.end, span.end);
continue;
}
if (previous && span.start < previous.end) span.start = previous.end;
if (span.end > span.start) result.push(span);
}
return result;
}
function fromLineOwners(content, owners) {
const spans = [];
let offset = 0;
content.split("\n").forEach((line, index, lines) => {
const length = line.length + (index < lines.length - 1 ? 1 : 0);
const owner = String(owners[index] || "");
if (owner && length > 0) spans.push({ start: offset, end: offset + length, owner });
offset += length;
});
return normalize(spans, content.length);
}
export function parseAuthorship(content, raw) {
let parsed;
try { parsed = typeof raw === "string" ? JSON.parse(raw || "[]") : raw; } catch { parsed = []; }
if (Array.isArray(parsed)) return { version: VERSION, spans: fromLineOwners(content, parsed) };
if (parsed && parsed.version === VERSION && Array.isArray(parsed.spans)) {
return { version: VERSION, spans: normalize(parsed.spans, content.length) };
}
return { version: VERSION, spans: [] };
}
export function serializeAuthorship(model, contentLength) {
return JSON.stringify({ version: VERSION, spans: normalize(model?.spans, contentLength) });
}
export function applyAuthorshipEdit(model, previousText, nextText, owner) {
if (previousText === nextText) return parseAuthorship(nextText, model);
let prefix = 0;
const shared = Math.min(previousText.length, nextText.length);
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
let oldSuffix = previousText.length;
let newSuffix = nextText.length;
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
oldSuffix--;
newSuffix--;
}
const removedLength = oldSuffix - prefix;
const insertedLength = newSuffix - prefix;
const delta = insertedLength - removedLength;
const updated = [];
for (const source of normalize(model?.spans, previousText.length)) {
if (source.end <= prefix) {
updated.push({ ...source });
continue;
}
if (source.start >= oldSuffix) {
updated.push({ start: source.start + delta, end: source.end + delta, owner: source.owner });
continue;
}
if (source.start < prefix) updated.push({ start: source.start, end: prefix, owner: source.owner });
if (source.end > oldSuffix) updated.push({ start: prefix + insertedLength, end: source.end + delta, owner: source.owner });
}
if (insertedLength > 0 && owner) updated.push({ start: prefix, end: prefix + insertedLength, owner: String(owner) });
return { version: VERSION, spans: normalize(updated, nextText.length) };
}
export function replaceAuthorshipOwner(model, matcher, replacement, contentLength) {
return {
version: VERSION,
spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength),
};
}
export function lineOwners(content, model) {
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
return starts.map((start, index) => {
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
const totals = new Map();
for (const span of model?.spans || []) {
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (overlap) totals.set(span.owner, (totals.get(span.owner) || 0) + overlap);
}
return [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "";
});
}
export function renderAuthorshipLayer(layer, editor, model, colorFor) {
if (!layer) return;
const style = getComputedStyle(editor);
layer.style.left = `${editor.offsetLeft}px`;
layer.style.paddingTop = style.paddingTop;
layer.style.paddingRight = style.paddingRight;
layer.style.paddingBottom = style.paddingBottom;
layer.style.paddingLeft = style.paddingLeft;
layer.style.fontFamily = style.fontFamily;
layer.style.fontSize = style.fontSize;
layer.style.fontWeight = style.fontWeight;
layer.style.lineHeight = style.lineHeight;
layer.style.letterSpacing = style.letterSpacing;
const text = editor.value;
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const span of normalize(model?.spans, text.length)) {
if (span.start > cursor) fragment.append(document.createTextNode(text.slice(cursor, span.start)));
const mark = document.createElement("span");
mark.className = "authorship-fragment";
mark.style.setProperty("--owner", colorFor(span.owner));
mark.textContent = text.slice(span.start, span.end);
mark.title = span.owner.split("\u001f", 1)[0];
fragment.append(mark);
cursor = span.end;
}
if (cursor < text.length) fragment.append(document.createTextNode(text.slice(cursor)));
if (!text.endsWith("\n")) fragment.append(document.createTextNode("\n"));
layer.replaceChildren(fragment);
layer.scrollTop = editor.scrollTop;
layer.scrollLeft = editor.scrollLeft;
}
+11 -10
View File
@@ -2,6 +2,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { applyAuthorshipEdit, lineOwners, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
@@ -14,13 +15,13 @@ import { askConfirm } from "@rustpad/modal";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
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 editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
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("workspace", workspaceSlug, shareToken);
let accessToken = shareToken || getAuthToken() || getAccessToken("workspace", workspaceSlug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), owners = [];
let accessToken = shareToken || getAuthToken() || getAccessToken("workspace", workspaceSlug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle");
lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
@@ -49,8 +50,7 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p
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 owners = lineOwners(editor.value, authorship);
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("");
@@ -62,6 +62,7 @@ function renderGutter() {
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("");
renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
}
@@ -141,7 +142,7 @@ function replaceTableCell(line, index, value) {
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
function 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 applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); }
function connect() { socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
@@ -170,20 +171,20 @@ 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);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(userColorPicker.value);
if (socket) socket.update(editor.value, JSON.stringify(owners));
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
});
window.addEventListener("storage", event => {
if (event.key !== storedColorKey(nickname)) return;
const replacement = currentOwner();
owners = owners.map(owner => ownerName(owner) === nickname ? replacement : owner);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(currentUserColor() || null);
if (socket) socket.update(editor.value, JSON.stringify(owners));
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
});
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); });
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }) }); accessToken = result.access_token; setAccessToken("workspace", workspaceSlug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
document.querySelector("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; });
+8 -8
View File
@@ -2,6 +2,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { applyAuthorshipEdit, lineOwners, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
@@ -12,13 +13,13 @@ 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 editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
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 = [];
let accessToken = shareToken || getAuthToken() || getAccessToken("pad", slug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
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";
@@ -45,8 +46,7 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p
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 owners = lineOwners(editor.value, authorship);
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("");
@@ -138,7 +138,7 @@ function replaceTableCell(line, index, value) {
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
function 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 applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); }
async function loadFiles({ open = false } = {}) {
try {
@@ -165,12 +165,12 @@ 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);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(userColorPicker.value);
if (socket) socket.update(editor.value, JSON.stringify(owners));
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
});
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); });
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }) }); accessToken = result.access_token; setAccessToken("pad", slug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
document.querySelector("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; });
+1 -1
View File
@@ -100,7 +100,7 @@
<div class="column-label">Editor</div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
</div>
</div>
+1 -1
View File
@@ -88,7 +88,7 @@
<div class="column-label">Editor</div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
</div>
</div>