diff --git a/Cargo.lock b/Cargo.lock
index 395ccf1..2c828da 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2674,7 +2674,7 @@ dependencies = [
[[package]]
name = "rustpad"
-version = "0.2.89"
+version = "0.2.90"
dependencies = [
"argon2",
"aws-config",
diff --git a/Cargo.toml b/Cargo.toml
index ccec80b..c0a517b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
-version = "0.2.89"
+version = "0.2.90"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
diff --git a/src/assets.rs b/src/assets.rs
index 5013996..0e84e41 100644
--- a/src/assets.rs
+++ b/src/assets.rs
@@ -20,6 +20,7 @@ const MODULES: &[&str] = &[
"collaboration",
"collaboration-session",
"connection-state",
+ "dom-reconcile",
"editor-format",
"emoji-data",
"emoji-groups",
diff --git a/static/css/styles.css b/static/css/styles.css
index 84247b6..ea59e28 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -6318,6 +6318,13 @@ dialog::backdrop {
}
}
+/* The author labels scroll as one layer; clipping still belongs to .owner-labels. */
+.owner-labels-canvas {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
.owner-label-group {
position: absolute;
right: calc(var(--editor-rendered-font-size, 14px) * .57);
diff --git a/static/js/authorship.js b/static/js/authorship.js
index 5643f6d..d07fa2d 100644
--- a/static/js/authorship.js
+++ b/static/js/authorship.js
@@ -116,13 +116,34 @@ export function authorshipOwners(model) {
}
export function lineAuthors(content, model) {
+ const spans = model?.spans || [];
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
+
+ // Parsed authorship spans are sorted and non-overlapping. Retain the old
+ // result for non-canonical callers instead of silently changing semantics.
+ let canonical = true;
+ for (let i = 0, previousEnd = 0; i < spans.length; i++) {
+ const span = spans[i];
+ if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end)
+ || span.start < previousEnd || span.end < span.start) {
+ canonical = false;
+ break;
+ }
+ previousEnd = span.end;
+ }
+
+ let cursor = 0;
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 || []) {
+ if (canonical) {
+ while (cursor < spans.length && spans[cursor].end <= start) cursor++;
+ }
+ for (let i = canonical ? cursor : 0; i < spans.length; i++) {
+ const span = spans[i];
+ if (canonical && span.start >= end) break;
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (!overlap) continue;
const identity = ownerIdentity(span.owner);
diff --git a/static/js/dom-reconcile.js b/static/js/dom-reconcile.js
new file mode 100644
index 0000000..a47bf9a
--- /dev/null
+++ b/static/js/dom-reconcile.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
+ * Source-Available Code / Dual-Licensed.
+ * See LICENSE.md for terms.
+ */
+
+// Preserve existing nodes. Remove stale entries first: otherwise deleting a
+// user near the start of a 1000-person list would move every following node.
+// `desired` must contain each node at most once.
+export function reconcileChildren(container, desired) {
+ const retained = new Set(desired);
+ for (const node of [...container.childNodes]) {
+ if (!retained.has(node)) node.remove();
+ }
+ let cursor = container.firstChild;
+ for (const node of desired) {
+ if (node === cursor) cursor = cursor.nextSibling;
+ else container.insertBefore(node, cursor);
+ }
+}
diff --git a/static/js/markdown.js b/static/js/markdown.js
index ce8b188..9f882fc 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -420,11 +420,12 @@ export function alignPreviewLineNumbers(root) {
const styles = getComputedStyle(root);
const targetLeft = parseFloat(styles.paddingLeft || "0") - 50;
const rootLeft = root.getBoundingClientRect().left;
+ const positions = [];
+ // Finish all geometry reads before writing CSS properties. Interleaved
+ // reads and writes force a full layout for each source line in Chromium.
root.querySelectorAll(".preview-source-line").forEach(line => {
const lineRect = line.getBoundingClientRect();
const lineLeft = lineRect.left - rootLeft + root.scrollLeft;
- line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`);
-
const textRect = firstRenderedTextRect(line);
const lineStyles = getComputedStyle(line);
const gutterStyles = getComputedStyle(line, "::before");
@@ -432,9 +433,16 @@ export function alignPreviewLineNumbers(root) {
const textLineHeight = textRect?.height || 0;
const borderTop = parseFloat(lineStyles.borderTopWidth) || 0;
const textTop = textRect ? textRect.top - lineRect.top - borderTop : 0;
- const gutterTop = Math.max(0, textTop + (textLineHeight - gutterLineHeight) / 2);
- line.style.setProperty("--preview-line-top", `${gutterTop}px`);
+ positions.push({
+ line,
+ left: `${targetLeft - lineLeft}px`,
+ top: `${Math.max(0, textTop + (textLineHeight - gutterLineHeight) / 2)}px`,
+ });
});
+ for (const position of positions) {
+ position.line.style.setProperty("--preview-line-left", position.left);
+ position.line.style.setProperty("--preview-line-top", position.top);
+ }
}
function stripFenceIndent(line, columns) {
diff --git a/static/js/note-editor.js b/static/js/note-editor.js
index fbfacc7..e78f94a 100644
--- a/static/js/note-editor.js
+++ b/static/js/note-editor.js
@@ -22,6 +22,7 @@ import { createMentionNavigator } from "@rustpad/mention-navigation";
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
import { previewEditingHost } from "@rustpad/preview-edit";
import { createRenderQueue } from "@rustpad/render-queue";
+import { reconcileChildren } from "@rustpad/dom-reconcile";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, setMarkdownMentions, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
@@ -323,7 +324,44 @@ export function startNoteEditor(adapter) {
syncMobileEditorControls();
updateCurrentUser(); return info;
}
- function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = t(entries.length === 1 ? "editor.user" : "editor.users", { count: entries.length }, `${entries.length} ${entries.length === 1 ? "user" : "users"}`); roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || t("editor.guest", {}, "Guest"); li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = t("editor.noActiveUsers", {}, "No active users"); roomUsers.append(li); } renderGutter(); }
+ // Presence is independent of document text, authorship spans and the gutter.
+ // Match by name, preserving duplicates and their DOM nodes between snapshots.
+ function updatePresence(users) {
+ const entries = Array.isArray(users) ? users : [];
+ presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {});
+ roomCount.textContent = t(entries.length === 1 ? "editor.user" : "editor.users", { count: entries.length }, `${entries.length} ${entries.length === 1 ? "user" : "users"}`);
+ const existing = new Map();
+ for (const li of roomUsers.children) {
+ if (li.dataset.presenceName === undefined) continue;
+ const bucket = existing.get(li.dataset.presenceName) || [];
+ bucket.push(li);
+ existing.set(li.dataset.presenceName, bucket);
+ }
+ const nodes = presenceUsers.map(user => {
+ const name = String(user.name || "");
+ const bucket = existing.get(name);
+ const li = bucket?.shift() || document.createElement("li");
+ const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
+ const displayName = name || t("editor.guest", {}, "Guest");
+ if (!li.classList.contains("room-user")) {
+ li.className = "room-user";
+ li.append(document.createElement("span"), document.createElement("span"));
+ li.firstElementChild.className = "room-user__dot";
+ }
+ li.dataset.presenceName = name;
+ if (li.firstElementChild.style.getPropertyValue("--owner") !== color) li.firstElementChild.style.setProperty("--owner", color);
+ if (li.lastElementChild.textContent !== displayName) li.lastElementChild.textContent = displayName;
+ if (li.title !== displayName) li.title = displayName;
+ return li;
+ });
+ if (!nodes.length) {
+ const empty = document.createElement("li");
+ empty.textContent = t("editor.noActiveUsers", {}, "No active users");
+ nodes.push(empty);
+ }
+ reconcileChildren(roomUsers, nodes);
+ renderParticipantBadges(authorshipColorsEnabled ? renderedAuthorshipOwners : []);
+ }
function updateLatency(ms) {
const text = Number.isFinite(ms) ? `${ms} ms` : "โ ms";
socketLatency.textContent = text;
@@ -476,6 +514,7 @@ export function startNoteEditor(adapter) {
}
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await loadHighlight(); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
async function renderMediaPlayers() { const nodes = preview.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(preview); } catch { } }
+ let renderedAuthorshipOwners = [];
function renderParticipantBadges(owners) {
if (!participantBadges) return;
const people = new Map();
@@ -487,19 +526,26 @@ export function startNoteEditor(adapter) {
}
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
const compact = people.size > 4;
- participantBadges.replaceChildren(...[...people.values()].map(person => {
- const badge = document.createElement("span");
+ const previous = new Map([...participantBadges.children].map(badge => [badge.dataset.personName, badge]));
+ const nodes = [...people.values()].map(person => {
+ const badge = previous.get(person.name) || document.createElement("span");
+ const label = compact && person.compactName ? person.compactName : person.name;
badge.className = "participant-badge";
- badge.style.setProperty("--owner", person.color);
- badge.textContent = compact && person.compactName ? person.compactName : person.name;
- badge.title = person.name;
+ badge.dataset.personName = person.name;
+ if (badge.style.getPropertyValue("--owner") !== person.color) badge.style.setProperty("--owner", person.color);
+ if (badge.textContent !== label) badge.textContent = label;
+ if (badge.title !== person.name) badge.title = person.name;
return badge;
- }));
+ });
+ reconcileChildren(participantBadges, nodes);
}
function syncOwnerLabels() {
- ownerLabels.querySelectorAll(".owner-label-group[data-content-top]").forEach(group => {
- group.style.top = `${Number(group.dataset.contentTop) - editor.scrollTop}px`;
- });
+ // One composited transform instead of rewriting every author's position
+ // on every scroll event (important for thousands of distinct authors).
+ const canvas = ownerLabels.firstElementChild;
+ if (canvas?.classList.contains("owner-labels-canvas")) {
+ canvas.style.transform = `translate3d(0, ${-editor.scrollTop}px, 0)`;
+ }
}
function syncEditorLayers() {
@@ -508,6 +554,9 @@ export function startNoteEditor(adapter) {
syncOwnerLabels();
}
+ let gutterLineCount = 0;
+ let gutterLineHeight = 0;
+ let highlightedGutterLine = null;
function renderGutter() {
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
@@ -517,6 +566,7 @@ export function startNoteEditor(adapter) {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount });
const owners = authorshipOwners(authorship);
+ renderedAuthorshipOwners = owners;
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full";
@@ -527,12 +577,27 @@ export function startNoteEditor(adapter) {
editorWorkspace.dataset.authorshipMode = authorshipMode;
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, 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) => `
`).join("");
+ // Physical rows represent sequential line numbers, not specific text lines.
+ // Reuse them across character edits and add/remove only the changed tail.
+ if (gutterLineHeight !== lineHeight) {
+ for (const row of gutter.children) row.style.height = `${lineHeight}px`;
+ gutterLineHeight = lineHeight;
+ }
+ if (lineCount > gutterLineCount) {
+ let html = "";
+ for (let i = gutterLineCount; i < lineCount; i++) {
+ html += ``;
+ }
+ gutter.insertAdjacentHTML("beforeend", html);
+ } else if (lineCount < gutterLineCount) {
+ for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove();
+ }
+ gutterLineCount = lineCount;
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
if (full) {
let previousAuthorSignature = null;
- ownerLabels.innerHTML = lines.map((_, i) => {
+ ownerLabels.innerHTML = `