This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 22:03:06 +02:00
1 parent 0abc8e871d
commit 4da41a1404
10 files changed
+435 -26

No files matched your search

Generated
+1 -1
View File
@@ -2674,7 +2674,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.89"
version = "0.2.90"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -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"
+1
View File
@@ -20,6 +20,7 @@ const MODULES: &[&str] = &[
"collaboration",
"collaboration-session",
"connection-state",
"dom-reconcile",
"editor-format",
"emoji-data",
"emoji-groups",
+7
View File
@@ -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);
+22 -1
View File
@@ -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);
+20
View File
@@ -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);
}
}
+12 -4
View File
@@ -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) {
+140 -19
View File
@@ -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) => `<div class="line-number-row" style="height:${lineHeight}px"><button class="line-number-button" type="button" tabindex="-1" data-line="${i + 1}" aria-label="Copy link to line ${i + 1}">${i + 1}</button></div>`).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 += `<div class="line-number-row" style="height:${lineHeight}px"><button class="line-number-button" type="button" tabindex="-1" data-line="${i + 1}" aria-label="Copy link to line ${i + 1}">${i + 1}</button></div>`;
}
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 = `<div class="owner-labels-canvas">${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 => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-label-group" data-content-top="${top}">${badges}</span>`;
}).join("");
return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join("")}</div>`;
} else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
const linkedLine = lineFromHash(location.hash, lineCount);
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,8 +1310,20 @@ 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;
// 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))
@@ -1229,20 +1336,25 @@ export function startNoteEditor(adapter) {
});
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) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).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,10 +2605,15 @@ 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", () => {
if (editorScrollFrame) return;
editorScrollFrame = requestAnimationFrame(() => {
editorScrollFrame = 0;
syncEditorLayers();
syncPreviewScroll();
});
});
editor.addEventListener("input", event => {
if (!canEditDocument() && !applyingRemote) {
applyCollaborativeView({ resetHistory: true });
+157
View File
@@ -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 = '''<!doctype html><html><head><meta charset="utf-8"><style>
.owner-labels { position: relative; height: 100px; width: 250px; overflow:hidden; }
.owner-labels-canvas { position:absolute;inset:0; }
.owner-label-group { position:absolute;right:12px;transform:translateY(-50%); }
#preview, #original { width:600px; height: 350px; overflow:auto; padding:24px; border:1px solid #ccc; }
.preview-source-line {display:block; position:relative; min-height: 24px; }
.preview-source-line::before {content:attr(data-source-line); position:absolute;left:-50px;line-height:16px;font-size:13px;}
</style></head><body>
<ul id="users"></ul><span id="room-count"></span><ul id="room-users"></ul><div id="participant-badges"></div>
<div id="owner-labels" class="owner-labels"><div class="owner-labels-canvas"><span class="owner-label-group" style="top:250px">Alice</span></div></div>
<article id="preview"></article><article id="original"></article>
<script type="module">
import { reconcileChildren } from "__RECONCILE__";
import { alignPreviewLineNumbers, firstRenderedTextRect } from "__MARKDOWN__";
const roomCount=document.querySelector('#room-count');
const roomUsers=document.querySelector('#room-users');
const participantBadges=document.querySelector('#participant-badges');
let presenceUsers=[];
const authorshipColorsEnabled=true, authorshipMode='simple';
const t=(key, vars, fallback)=>fallback;
const defaultColorFor=()=> '#444444';
const ownerName=value=>String(value).split('\u001f')[0];
const colorFor=()=> '#aabbcc';
function renderGutter() { throw new Error('Presence must not cause a gutter rebuild'); }
__BADGES_SOURCE__
__PRESENCE_SOURCE__
function check(result, label) {if (!result) throw new Error(label);}
const list = document.querySelector('#users');
const a = document.createElement('li'), b=document.createElement('li'), c=document.createElement('li'), d=document.createElement('li');
a.textContent='A';b.textContent='B';c.textContent='C';d.textContent='D';
list.append(a,b,c);
reconcileChildren(list,[c,a,d]);
check(list.children.length===3 && list.children[0]===c && list.children[1]===a && list.children[2]===d, 'reordering/removal/insertion');
const observer = new MutationObserver(()=>{});
observer.observe(list,{childList:true});
reconcileChildren(list,[c,a,d]);
check(observer.takeRecords().length === 0, 'unchanged list should not mutate the DOM');
observer.disconnect();
const participants=Array.from({length:1000},(_,i)=>({name:`User ${i}`,compact_name:`U${i}`,color:'#abcdef'}));
updatePresence(participants);
check(roomUsers.children.length===1000 && participantBadges.children.length===1000, '1000 participants rendered');
const rowRefs=Array.from(roomUsers.children);
const badgeRefs=Array.from(participantBadges.children);
updatePresence([...participants,{name:'New person',color:'#123456'}]);
check(roomUsers.children.length===1001 && roomUsers.children[0]===rowRefs[0] && roomUsers.children[999]===rowRefs[999], 'presence DOM reused');
check(participantBadges.children[0]===badgeRefs[0] && participantBadges.children[999]===badgeRefs[999], 'badge DOM reused');
const presenceMutations=new MutationObserver(()=>{});
presenceMutations.observe(roomUsers,{childList:true});
updatePresence([...participants.slice(1),{name:'New person',color:'#123456'}]);
const changes=presenceMutations.takeRecords();
check(changes.length===1 && changes[0].removedNodes.length===1, 'single early removal does not move remaining users');
presenceMutations.disconnect();
updatePresence([{name:'Duplicate',color:'#abcdef'},{name:'Duplicate',color:'#123456'}]);
check(roomUsers.children.length===2 && participantBadges.children.length===1, 'same-name participants count preserved, badge deduplicated');
updatePresence([{name:'<script>alert(1)<'+'/script>',color:'red'}]);
check(roomUsers.querySelector('script')===null && participantBadges.querySelector('script')===null, 'text remains escaped');
const ownerLabels=document.querySelector('#owner-labels');
const canvas=ownerLabels.firstElementChild;
const group=canvas.firstElementChild;
canvas.style.transform='translate3d(0,-170px,0)';
let top=group.getBoundingClientRect().top-ownerLabels.getBoundingClientRect().top;
check(Math.abs(top - (250 - 170 - group.getBoundingClientRect().height/2)) < 1, 'owner labels scroll as one canvas');
function oldAlign(root) {
const styles=getComputedStyle(root), targetLeft=parseFloat(styles.paddingLeft||'0')-50;
const rootLeft=root.getBoundingClientRect().left;
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), gutterStyles=getComputedStyle(line,'::before');
const gutterLineHeight=parseFloat(gutterStyles.lineHeight)||parseFloat(gutterStyles.fontSize)||0;
const textLineHeight=textRect?.height||0;
const borderTop=parseFloat(lineStyles.borderTopWidth)||0;
const textTop=textRect?textRect.top-lineRect.top-borderTop:0;
line.style.setProperty('--preview-line-top', `${Math.max(0,textTop+(textLineHeight-gutterLineHeight)/2)}px`);
});
}
const preview=document.querySelector('#preview'), original=document.querySelector('#original');
const rows=Array.from({length:150},(_,i)=>`<p class="preview-source-line" data-source-line="${i+1}">Row ${i} some text</p>`).join('');
preview.innerHTML=rows;original.innerHTML=rows;
oldAlign(original);
alignPreviewLineNumbers(preview);
const oldRows=original.querySelectorAll('.preview-source-line');
const newRows=preview.querySelectorAll('.preview-source-line');
for(let i=0;i<newRows.length;i++) {
for(const prop of ['--preview-line-left','--preview-line-top']) {
const before=parseFloat(oldRows[i].style.getPropertyValue(prop));
const after=parseFloat(newRows[i].style.getPropertyValue(prop));
check(Math.abs(before-after)<0.05, 'alignment parity at '+i+' '+prop+': '+before+' != '+after);
}
}
// Cached source geometry must not be remeasured during scroll, but must
// rebuild when the preview DOM/layout changes.
__GEOMETRY_SOURCE__
const firstPositions=previewLinePositions();
check(previewLinePositions()===firstPositions, 'preview geometry cached');
preview.scrollTop=100;
check(previewLinePositions()===firstPositions, 'scroll does not discard geometry');
preview.insertAdjacentHTML('beforeend','<p class="preview-source-line" data-source-line="151">Last line</p>');
await new Promise(resolve=>setTimeout(resolve,50));
const changedPositions=previewLinePositions();
check(changedPositions!==firstPositions && changedPositions.length===firstPositions.length+1, 'DOM mutation invalidates geometry');
window.frontendTestsPassed=true;
</script></body></html>'''.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()
+74
View File
@@ -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`);