${snippet}
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) => `
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 `${snippet}
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})`; 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) => `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 `${snippet}
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})`; 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 @@