${snippet}
diff --git a/static/js/authorship.js b/static/js/authorship.js index e709d72..8167591 100644 --- a/static/js/authorship.js +++ b/static/js/authorship.js @@ -1,4 +1,9 @@ const VERSION = 2; +const OWNER_COLOR_SEPARATOR = "\u001f"; + +function ownerIdentity(owner) { + return String(owner || "").split(OWNER_COLOR_SEPARATOR, 1)[0]; +} function normalize(spans, length) { const sorted = (Array.isArray(spans) ? spans : []) @@ -93,7 +98,12 @@ export function replaceAuthorshipOwner(model, matcher, replacement, contentLengt export function authorshipOwners(model) { - return [...new Set((model?.spans || []).map(span => span.owner).filter(Boolean))]; + const owners = new Map(); + for (const span of model?.spans || []) { + const identity = ownerIdentity(span.owner); + if (identity) owners.set(identity, span.owner); + } + return [...owners.values()]; } export function lineAuthors(content, model) { @@ -102,11 +112,17 @@ export function lineAuthors(content, model) { return starts.map((start, index) => { const end = index + 1 < starts.length ? starts[index + 1] : content.length; const totals = new Map(); + const representatives = 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); + if (!overlap) continue; + const identity = ownerIdentity(span.owner); + totals.set(identity, (totals.get(identity) || 0) + overlap); + representatives.set(identity, span.owner); } - return [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([owner]) => owner); + return [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([identity]) => representatives.get(identity)); }); } @@ -142,11 +158,16 @@ export function lineOwners(content, model) { return starts.map((start, index) => { const end = index + 1 < starts.length ? starts[index + 1] : content.length; const totals = new Map(); + const representatives = 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); + if (!overlap) continue; + const identity = ownerIdentity(span.owner); + totals.set(identity, (totals.get(identity) || 0) + overlap); + representatives.set(identity, span.owner); } - return [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || ""; + const identity = [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; + return identity ? representatives.get(identity) : ""; }); } diff --git a/static/js/note.js b/static/js/note.js index 0b452a7..ab589b4 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -32,7 +32,7 @@ function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h 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 colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[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"; } @@ -196,7 +196,7 @@ window.addEventListener("storage", event => { socket?.setColor(currentUserColor() || null); if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); }); -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); }); +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 = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); 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 `${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("#delete-note").addEventListener("click", async () => { if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return; try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }) }); location.assign(`/w/${encodeURIComponent(workspaceSlug)}`); } catch (error) { toast(error.message); } }); diff --git a/static/js/pad.js b/static/js/pad.js index 7a7a655..9f64178 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -30,7 +30,7 @@ function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h 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 colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[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"; } @@ -187,7 +187,7 @@ userColorPicker.addEventListener("input", () => { socket?.setColor(userColorPicker.value); if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); }); -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); }); +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 = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); 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 `${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"); });