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 = `
${lines.map((_, i) => { const authors = authorsByLine[i] || []; if (!authors.length) return ""; const top = paddingTop + i * lineHeight + lineHeight / 2; @@ -540,13 +605,17 @@ export function startNoteEditor(adapter) { if (signature === previousAuthorSignature) return ""; previousAuthorSignature = signature; const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join(""); - return `${badges}`; - }).join(""); + return `${badges}`; + }).join("")}
`; } else ownerLabels.replaceChildren(); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); else authorshipLayer.replaceChildren(); const linkedLine = lineFromHash(location.hash, lineCount); - gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked"); + if (highlightedGutterLine !== linkedLine) { + gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked"); + gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked"); + highlightedGutterLine = linkedLine; + } syncEditorLayers(); } @@ -1062,7 +1131,30 @@ export function startNoteEditor(adapter) { }; } + let previewPositionCache = null; + let cachedPreviewHeight = -1; + let cachedPreviewWidth = -1; + function invalidatePreviewPositions() { previewPositionCache = null; } + // The (rect.top - container.top + scrollTop) coordinates are invariant to + // scrolling; only rebuild them when layout/content actually changes. + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(invalidatePreviewPositions).observe(preview); + } + if (typeof MutationObserver !== "undefined") { + new MutationObserver(invalidatePreviewPositions).observe(preview, { + subtree: true, childList: true, characterData: true, + attributes: true, attributeFilter: ["open", "src", "class"], + }); + } + preview.addEventListener("toggle", invalidatePreviewPositions, true); + preview.addEventListener("load", invalidatePreviewPositions, true); + window.addEventListener("resize", invalidatePreviewPositions); + document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions); function previewLinePositions() { + // Height/width checks catch dynamic embeds that change geometry without + // a DOM mutation (e.g. a media player expanding after initialization). + const height = preview.scrollHeight, width = preview.clientWidth; + if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache; const previewRect = preview.getBoundingClientRect(); const positions = []; preview.querySelectorAll(".preview-source-line[data-source-line]").forEach(node => { @@ -1084,7 +1176,10 @@ export function startNoteEditor(adapter) { bottom: rect.bottom - previewRect.top + preview.scrollTop, }); }); - return positions.sort((left, right) => left.top - right.top || left.sourceLine - right.sourceLine); + previewPositionCache = positions.sort((left, right) => left.top - right.top || left.sourceLine - right.sourceLine); + cachedPreviewHeight = height; + cachedPreviewWidth = width; + return previewPositionCache; } function previewScrollAnchor() { @@ -1215,34 +1310,51 @@ export function startNoteEditor(adapter) { preview.scrollTop = Math.max(0, Math.min(range, snapshot.scrollTop || 0)); } + let previewStale = true; + let forcePreviewRender = false; function renderNow() { const previewViewport = pendingPreviewViewport; - pendingPreviewViewport = null; - const openDetailsLines = uiState.mode === "markdown" - ? new Set([...preview.querySelectorAll("details.markdown-details[open][data-source-line]")].map(node => node.dataset.sourceLine)) - : new Set(); - if (uiState.mode === "markdown") { - preview.classList.remove("preview--raw"); - preview.innerHTML = renderMarkdown(editor.value); - preview.querySelectorAll("details.markdown-details[data-source-line]").forEach(node => { - node.open = openDetailsLines.has(node.dataset.sourceLine); - }); - scheduleAliasFileRefresh(editor.value); - document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)"; - renderMermaid(); - renderCodeHighlight(); - renderMediaPlayers(); - } 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"; - } + // In editor-only view this DOM is display:none. Building a full Markdown + // tree (and highlighting / Mermaid) on each remote edit is wasted work. + // Keep the exceptional consumers working: heading deep-links and PDF export. + const headingRevealPending = uiState.mode === "markdown" && location.hash + && !/^#L[1-9]\d*$/i.test(location.hash) + && lastRevealedHeadingHash !== location.hash; + const previewNeededForPdf = forcePreviewRender || document.querySelector("#pdf-export-dialog")?.open; + const buildPreview = activeView() !== "edit" || previewNeededForPdf || headingRevealPending; + forcePreviewRender = false; + if (buildPreview) { + pendingPreviewViewport = null; + const openDetailsLines = uiState.mode === "markdown" + ? new Set([...preview.querySelectorAll("details.markdown-details[open][data-source-line]")].map(node => node.dataset.sourceLine)) + : new Set(); + if (uiState.mode === "markdown") { + preview.classList.remove("preview--raw"); + preview.innerHTML = renderMarkdown(editor.value); + preview.querySelectorAll("details.markdown-details[data-source-line]").forEach(node => { + node.open = openDetailsLines.has(node.dataset.sourceLine); + }); + scheduleAliasFileRefresh(editor.value); + document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)"; + if (activeView() !== "edit" || previewNeededForPdf) { + renderMermaid(); + renderCodeHighlight(); + renderMediaPlayers(); + } + } 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"; + } + previewStale = false; + invalidatePreviewPositions(); + } else previewStale = true; mentionNavigator.render({ markdown: uiState.mode === "markdown" }); - alignPreviewLineNumbers(preview); + if (buildPreview && previewLineToggle.checked && activeView() !== "edit") 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(); - requestAnimationFrame(() => { + if (buildPreview) requestAnimationFrame(() => { if (previewViewport) restorePreviewViewport(previewViewport); else syncPreviewScroll(); revealHeadingAnchor(); @@ -1809,7 +1921,7 @@ export function startNoteEditor(adapter) { toolbarCollapseToggle?.addEventListener("click", () => { toolbarCollapsed = !toolbarCollapsed; localStorage.setItem(notePreferenceKey("toolbar-collapsed"), toolbarCollapsed ? "on" : "off"); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); navbarCollapseToggle?.addEventListener("click", () => { navbarCollapsed = !navbarCollapsed; localStorage.setItem(notePreferenceKey("navbar-collapsed"), navbarCollapsed ? "on" : "off"); if (navbarCollapsed) setHeaderMenuOpen(false); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); }); - previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); }); + previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); if (previewLineToggle.checked && activeView() !== "edit") alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); }); compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); }); fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); @@ -2047,7 +2159,7 @@ export function startNoteEditor(adapter) { } } window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); }); - window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); }); + window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); }); preview.addEventListener("click", event => { const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null; if (!link) return; @@ -2170,6 +2282,10 @@ export function startNoteEditor(adapter) { let pdfExportBound = false; let pdfExportLoad = null; pdfExportArgs.button?.addEventListener("click", async event => { + if (previewStale) { + forcePreviewRender = true; + render(); + } if (pdfExportBound) return; event.stopImmediatePropagation(); pdfExportLoad ||= import("@rustpad/pdf-export").then(({ bindPdfExport }) => { @@ -2489,9 +2605,14 @@ export function startNoteEditor(adapter) { toast.info("The global profile color is active again.", { title: "Profile color restored" }); }); editor.addEventListener("keydown", continueIndentation); + let editorScrollFrame = 0; editor.addEventListener("scroll", () => { - syncEditorLayers(); - syncPreviewScroll(); + if (editorScrollFrame) return; + editorScrollFrame = requestAnimationFrame(() => { + editorScrollFrame = 0; + syncEditorLayers(); + syncPreviewScroll(); + }); }); editor.addEventListener("input", event => { if (!canEditDocument() && !applyingRemote) { diff --git a/tests/frontend-optimizations-browser.py b/tests/frontend-optimizations-browser.py new file mode 100644 index 0000000..138ef27 --- /dev/null +++ b/tests/frontend-optimizations-browser.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Isolated browser tests of DOM reconciliation, preview alignment and owner labels. +Run from the repository root. Does not emulate the server or OT protocol. +""" +from __future__ import annotations + +import base64 +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] + + +def module_url(content: str) -> str: + return "data:text/javascript;base64," + base64.b64encode(content.encode()).decode() + + +def main(): + reconcile = (ROOT / "static/js/dom-reconcile.js").read_text() + editor_source = (ROOT / "static/js/note-editor.js").read_text() + presence_source = editor_source.split(" function updatePresence(users) {", 1)[1].split(" function updateLatency", 1)[0] + presence_source = "function updatePresence(users) {" + presence_source + badges_source = editor_source.split(" let renderedAuthorshipOwners = [];", 1)[1].split(" function syncOwnerLabels()", 1)[0] + badges_source = "let renderedAuthorshipOwners = [];" + badges_source + geometry_source = "let previewPositionCache = null;" + editor_source.split(" let previewPositionCache = null;", 1)[1].split(" function previewScrollAnchor()", 1)[0] + markdown = (ROOT / "static/js/markdown.js").read_text() + markdown = "const EMOJI_SHORTCODES = {}; const t = (key, v, fallback) => fallback; const parseImageAlias = () => null;\n" + "\n".join( + line for line in markdown.splitlines() if not line.startswith("import ") + ) + "\nexport { firstRenderedTextRect };\n" + html = ''' +
+
Alice
+
+ '''.replace("__RECONCILE__", module_url(reconcile)).replace("__MARKDOWN__", module_url(markdown)).replace("__BADGES_SOURCE__", badges_source).replace("__PRESENCE_SOURCE__", presence_source).replace("__GEOMETRY_SOURCE__", geometry_source) + with sync_playwright() as playwright: + browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox", "--disable-dev-shm-usage"]) + try: + page = browser.new_page(viewport={"width": 1100, "height": 780}) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.set_content(html) + try: + page.wait_for_function("window.frontendTestsPassed===true", timeout=15000) + except Exception: + raise AssertionError(f"Browser errors: {errors}") + assert not errors, errors + print("PASS: keyed DOM list updates preserve node identity; identical list has zero mutations") + print("PASS: real presence update handles 1000 users with row/badge reuse, duplicates and XSS-safe text") + print("PASS: owner-label canvas transforms; preview alignment parity for 150 lines in Chromium") + print("PASS: preview geometry cache reuses measurements on scroll and invalidates on content change") + finally: + browser.close() + + +if __name__ == "__main__": + main() diff --git a/tests/frontend-optimizations-test.mjs b/tests/frontend-optimizations-test.mjs new file mode 100644 index 0000000..584bdec --- /dev/null +++ b/tests/frontend-optimizations-test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; + +const source = readFileSync('static/js/authorship.js', 'utf8'); +const { lineAuthors, parseAuthorship } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); + +// Reference implementation from 0.2.89; include it to detect attribution, +// color and tie-breaking regressions independently of the optimized code. +function referenceLineAuthors(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(); + 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) continue; + const identity = String(span.owner || '').split('\u001f', 1)[0]; + totals.set(identity, (totals.get(identity) || 0) + overlap); + representatives.set(identity, span.owner); + } + return [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([identity]) => representatives.get(identity)); + }); +} + +let seed = 42; +function random(n) { seed = (seed * 1664525 + 1013904223) >>> 0; return seed % n; } +const owners = ['A\u001f#ff0000', 'B', 'A\u001f#00ff00', 'C', 'D', '']; +for (let sample = 0; sample < 400; sample++) { + const content = Array.from({ length: 1 + random(65) }, (_, i) => { + const base = ['text', '', '๐Ÿ™‚ รฉรณล‚', ' x', '......'][random(5)]; + return base + (i ? '\n' : ''); + }).join(''); + const spans = []; + for (let i = 0; i < 4 + random(100); i++) { + const start = random(content.length + 1); + spans.push({ start, end: start + random(content.length + 4), owner: owners[random(owners.length)] }); + } + const canonical = parseAuthorship(content, { version: 2, spans }); + assert.deepEqual(lineAuthors(content, canonical), referenceLineAuthors(content, canonical), `canonical parity sample ${sample}`); + assert.deepEqual(lineAuthors(content, { spans }), referenceLineAuthors(content, { spans }), `noncanonical parity sample ${sample}`); +} +for (const content of ['', 'a', '\n', '๐Ÿ™‚\n\nB\n', 'line1\nline2\n']) { + for (const model of [{ spans: [] }, { spans: [{ start: 0, end: 999, owner: 'one' }] }]) { + assert.deepEqual(lineAuthors(content, model), referenceLineAuthors(content, model)); + } +} + +const n = 6000; +const lines = [], spans = []; +let offset = 0; +for (let i = 0; i < n; i++) { + const line = `row ${String(i).padStart(5, '0')} lorem ipsum\n`; + lines.push(line); + spans.push({ start: offset, end: offset + line.length, owner: `user${i % 37}` }); + offset += line.length; +} +const text = lines.join(''); +const model = { version: 2, spans }; +assert.deepEqual(lineAuthors(text, model), referenceLineAuthors(text, model)); +function medianDuration(fn) { + const ms = []; + for (let i = 0; i < 7; i++) { const start = performance.now(); fn(); ms.push(performance.now() - start); } + ms.sort((a, b) => a - b); + return ms[3]; +} +medianDuration(() => lineAuthors(text, model)); +const oldMs = medianDuration(() => referenceLineAuthors(text, model)); +const newMs = medianDuration(() => lineAuthors(text, model)); +console.log(`PASS: 400 randomized canonical + noncanonical parity cases; 6000 lines, 6000 spans: reference ${oldMs.toFixed(2)} ms; optimized ${newMs.toFixed(2)} ms; ${(oldMs / newMs).toFixed(1)}x`);