diff --git a/src/assets.rs b/src/assets.rs index bf8ca65..7d98f51 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -5,6 +5,7 @@ use axum::{ const MODULES: &[&str] = &[ "api", + "authorship", "auth-ui", "clipboard", "editor-format", diff --git a/static/css/styles.css b/static/css/styles.css index f5b1fe4..4a9f013 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -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; } +} diff --git a/static/js/authorship.js b/static/js/authorship.js new file mode 100644 index 0000000..fb0927c --- /dev/null +++ b/static/js/authorship.js @@ -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; +} diff --git a/static/js/note.js b/static/js/note.js index a8d185e..72f5c26 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -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) => `
${i + 1}
`).join(""); @@ -62,6 +62,7 @@ function renderGutter() { const label = owner !== owners[i - 1] ? `${escapeHtml(ownerName(owner))}` : ""; return `${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) => `
${escapeHtml(line) || "
"}
`).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 = '

Loading…

'; 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 `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; 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 = `

${escapeHtml(e.message)}

`; } }); 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 = ""; }); diff --git a/static/js/pad.js b/static/js/pad.js index 0c97350..7002661 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -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) => `
${i + 1}
`).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) => `
${escapeHtml(line) || "
"}
`).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 = '

Loading…

'; 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 `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; 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 = `

${escapeHtml(e.message)}

`; } }); 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 = ""; }); diff --git a/static/note.html b/static/note.html index 1842108..1b308eb 100644 --- a/static/note.html +++ b/static/note.html @@ -100,7 +100,7 @@
Editor
-
diff --git a/static/pad.html b/static/pad.html index d5b7546..1594601 100644 --- a/static/pad.html +++ b/static/pad.html @@ -88,7 +88,7 @@
Editor
-