v0.2.90
This commit is contained in:
1 parent
0abc8e871d
commit
4da41a1404
10 files changed
+458
-49
No files matched your search
Generated
+1
-1
@@ -2674,7 +2674,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.89"
|
version = "0.2.90"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.89"
|
version = "0.2.90"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const MODULES: &[&str] = &[
|
|||||||
"collaboration",
|
"collaboration",
|
||||||
"collaboration-session",
|
"collaboration-session",
|
||||||
"connection-state",
|
"connection-state",
|
||||||
|
"dom-reconcile",
|
||||||
"editor-format",
|
"editor-format",
|
||||||
"emoji-data",
|
"emoji-data",
|
||||||
"emoji-groups",
|
"emoji-groups",
|
||||||
|
|||||||
@@ -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 {
|
.owner-label-group {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: calc(var(--editor-rendered-font-size, 14px) * .57);
|
right: calc(var(--editor-rendered-font-size, 14px) * .57);
|
||||||
|
|||||||
+22
-1
@@ -116,13 +116,34 @@ export function authorshipOwners(model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function lineAuthors(content, model) {
|
export function lineAuthors(content, model) {
|
||||||
|
const spans = model?.spans || [];
|
||||||
const starts = [0];
|
const starts = [0];
|
||||||
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
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) => {
|
return starts.map((start, index) => {
|
||||||
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
||||||
const totals = new Map();
|
const totals = new Map();
|
||||||
const representatives = 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));
|
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
|
||||||
if (!overlap) continue;
|
if (!overlap) continue;
|
||||||
const identity = ownerIdentity(span.owner);
|
const identity = ownerIdentity(span.owner);
|
||||||
|
|||||||
@@ -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
@@ -420,11 +420,12 @@ export function alignPreviewLineNumbers(root) {
|
|||||||
const styles = getComputedStyle(root);
|
const styles = getComputedStyle(root);
|
||||||
const targetLeft = parseFloat(styles.paddingLeft || "0") - 50;
|
const targetLeft = parseFloat(styles.paddingLeft || "0") - 50;
|
||||||
const rootLeft = root.getBoundingClientRect().left;
|
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 => {
|
root.querySelectorAll(".preview-source-line").forEach(line => {
|
||||||
const lineRect = line.getBoundingClientRect();
|
const lineRect = line.getBoundingClientRect();
|
||||||
const lineLeft = lineRect.left - rootLeft + root.scrollLeft;
|
const lineLeft = lineRect.left - rootLeft + root.scrollLeft;
|
||||||
line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`);
|
|
||||||
|
|
||||||
const textRect = firstRenderedTextRect(line);
|
const textRect = firstRenderedTextRect(line);
|
||||||
const lineStyles = getComputedStyle(line);
|
const lineStyles = getComputedStyle(line);
|
||||||
const gutterStyles = getComputedStyle(line, "::before");
|
const gutterStyles = getComputedStyle(line, "::before");
|
||||||
@@ -432,9 +433,16 @@ export function alignPreviewLineNumbers(root) {
|
|||||||
const textLineHeight = textRect?.height || 0;
|
const textLineHeight = textRect?.height || 0;
|
||||||
const borderTop = parseFloat(lineStyles.borderTopWidth) || 0;
|
const borderTop = parseFloat(lineStyles.borderTopWidth) || 0;
|
||||||
const textTop = textRect ? textRect.top - lineRect.top - borderTop : 0;
|
const textTop = textRect ? textRect.top - lineRect.top - borderTop : 0;
|
||||||
const gutterTop = Math.max(0, textTop + (textLineHeight - gutterLineHeight) / 2);
|
positions.push({
|
||||||
line.style.setProperty("--preview-line-top", `${gutterTop}px`);
|
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) {
|
function stripFenceIndent(line, columns) {
|
||||||
|
|||||||
+163
-42
@@ -22,6 +22,7 @@ import { createMentionNavigator } from "@rustpad/mention-navigation";
|
|||||||
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
|
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
|
||||||
import { previewEditingHost } from "@rustpad/preview-edit";
|
import { previewEditingHost } from "@rustpad/preview-edit";
|
||||||
import { createRenderQueue } from "@rustpad/render-queue";
|
import { createRenderQueue } from "@rustpad/render-queue";
|
||||||
|
import { reconcileChildren } from "@rustpad/dom-reconcile";
|
||||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, setMarkdownMentions, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, setMarkdownMentions, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
||||||
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||||
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
||||||
@@ -323,7 +324,44 @@ export function startNoteEditor(adapter) {
|
|||||||
syncMobileEditorControls();
|
syncMobileEditorControls();
|
||||||
updateCurrentUser(); return info;
|
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) {
|
function updateLatency(ms) {
|
||||||
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
|
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
|
||||||
socketLatency.textContent = text;
|
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 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 { } }
|
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) {
|
function renderParticipantBadges(owners) {
|
||||||
if (!participantBadges) return;
|
if (!participantBadges) return;
|
||||||
const people = new Map();
|
const people = new Map();
|
||||||
@@ -487,19 +526,26 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
|
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
|
||||||
const compact = people.size > 4;
|
const compact = people.size > 4;
|
||||||
participantBadges.replaceChildren(...[...people.values()].map(person => {
|
const previous = new Map([...participantBadges.children].map(badge => [badge.dataset.personName, badge]));
|
||||||
const badge = document.createElement("span");
|
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.className = "participant-badge";
|
||||||
badge.style.setProperty("--owner", person.color);
|
badge.dataset.personName = person.name;
|
||||||
badge.textContent = compact && person.compactName ? person.compactName : person.name;
|
if (badge.style.getPropertyValue("--owner") !== person.color) badge.style.setProperty("--owner", person.color);
|
||||||
badge.title = person.name;
|
if (badge.textContent !== label) badge.textContent = label;
|
||||||
|
if (badge.title !== person.name) badge.title = person.name;
|
||||||
return badge;
|
return badge;
|
||||||
}));
|
});
|
||||||
|
reconcileChildren(participantBadges, nodes);
|
||||||
}
|
}
|
||||||
function syncOwnerLabels() {
|
function syncOwnerLabels() {
|
||||||
ownerLabels.querySelectorAll(".owner-label-group[data-content-top]").forEach(group => {
|
// One composited transform instead of rewriting every author's position
|
||||||
group.style.top = `${Number(group.dataset.contentTop) - editor.scrollTop}px`;
|
// 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() {
|
function syncEditorLayers() {
|
||||||
@@ -508,6 +554,9 @@ export function startNoteEditor(adapter) {
|
|||||||
syncOwnerLabels();
|
syncOwnerLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let gutterLineCount = 0;
|
||||||
|
let gutterLineHeight = 0;
|
||||||
|
let highlightedGutterLine = null;
|
||||||
function renderGutter() {
|
function renderGutter() {
|
||||||
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
||||||
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.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 lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||||
const lines = Array.from({ length: lineCount });
|
const lines = Array.from({ length: lineCount });
|
||||||
const owners = authorshipOwners(authorship);
|
const owners = authorshipOwners(authorship);
|
||||||
|
renderedAuthorshipOwners = owners;
|
||||||
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
|
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
|
||||||
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
||||||
const full = authorshipMode === "full";
|
const full = authorshipMode === "full";
|
||||||
@@ -527,12 +577,27 @@ export function startNoteEditor(adapter) {
|
|||||||
editorWorkspace.dataset.authorshipMode = authorshipMode;
|
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;
|
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.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-line-height", `${lineHeight}px`);
|
||||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||||
if (full) {
|
if (full) {
|
||||||
let previousAuthorSignature = null;
|
let previousAuthorSignature = null;
|
||||||
ownerLabels.innerHTML = lines.map((_, i) => {
|
ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => {
|
||||||
const authors = authorsByLine[i] || [];
|
const authors = authorsByLine[i] || [];
|
||||||
if (!authors.length) return "";
|
if (!authors.length) return "";
|
||||||
const top = paddingTop + i * lineHeight + lineHeight / 2;
|
const top = paddingTop + i * lineHeight + lineHeight / 2;
|
||||||
@@ -540,13 +605,17 @@ export function startNoteEditor(adapter) {
|
|||||||
if (signature === previousAuthorSignature) return "";
|
if (signature === previousAuthorSignature) return "";
|
||||||
previousAuthorSignature = signature;
|
previousAuthorSignature = signature;
|
||||||
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
|
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>`;
|
return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
|
||||||
}).join("");
|
}).join("")}</div>`;
|
||||||
} else ownerLabels.replaceChildren();
|
} else ownerLabels.replaceChildren();
|
||||||
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
||||||
else authorshipLayer.replaceChildren();
|
else authorshipLayer.replaceChildren();
|
||||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
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();
|
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() {
|
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 previewRect = preview.getBoundingClientRect();
|
||||||
const positions = [];
|
const positions = [];
|
||||||
preview.querySelectorAll(".preview-source-line[data-source-line]").forEach(node => {
|
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,
|
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() {
|
function previewScrollAnchor() {
|
||||||
@@ -1215,34 +1310,51 @@ export function startNoteEditor(adapter) {
|
|||||||
preview.scrollTop = Math.max(0, Math.min(range, snapshot.scrollTop || 0));
|
preview.scrollTop = Math.max(0, Math.min(range, snapshot.scrollTop || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previewStale = true;
|
||||||
|
let forcePreviewRender = false;
|
||||||
function renderNow() {
|
function renderNow() {
|
||||||
const previewViewport = pendingPreviewViewport;
|
const previewViewport = pendingPreviewViewport;
|
||||||
pendingPreviewViewport = null;
|
// In editor-only view this DOM is display:none. Building a full Markdown
|
||||||
const openDetailsLines = uiState.mode === "markdown"
|
// tree (and highlighting / Mermaid) on each remote edit is wasted work.
|
||||||
? new Set([...preview.querySelectorAll("details.markdown-details[open][data-source-line]")].map(node => node.dataset.sourceLine))
|
// Keep the exceptional consumers working: heading deep-links and PDF export.
|
||||||
: new Set();
|
const headingRevealPending = uiState.mode === "markdown" && location.hash
|
||||||
if (uiState.mode === "markdown") {
|
&& !/^#L[1-9]\d*$/i.test(location.hash)
|
||||||
preview.classList.remove("preview--raw");
|
&& lastRevealedHeadingHash !== location.hash;
|
||||||
preview.innerHTML = renderMarkdown(editor.value);
|
const previewNeededForPdf = forcePreviewRender || document.querySelector("#pdf-export-dialog")?.open;
|
||||||
preview.querySelectorAll("details.markdown-details[data-source-line]").forEach(node => {
|
const buildPreview = activeView() !== "edit" || previewNeededForPdf || headingRevealPending;
|
||||||
node.open = openDetailsLines.has(node.dataset.sourceLine);
|
forcePreviewRender = false;
|
||||||
});
|
if (buildPreview) {
|
||||||
scheduleAliasFileRefresh(editor.value);
|
pendingPreviewViewport = null;
|
||||||
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
|
const openDetailsLines = uiState.mode === "markdown"
|
||||||
renderMermaid();
|
? new Set([...preview.querySelectorAll("details.markdown-details[open][data-source-line]")].map(node => node.dataset.sourceLine))
|
||||||
renderCodeHighlight();
|
: new Set();
|
||||||
renderMediaPlayers();
|
if (uiState.mode === "markdown") {
|
||||||
} else {
|
preview.classList.remove("preview--raw");
|
||||||
preview.classList.add("preview--raw");
|
preview.innerHTML = renderMarkdown(editor.value);
|
||||||
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("");
|
preview.querySelectorAll("details.markdown-details[data-source-line]").forEach(node => {
|
||||||
document.querySelector("#preview-label").textContent = "Text preview";
|
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) => `<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" });
|
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("#characters").textContent = `${editor.value.length} characters`;
|
||||||
document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`;
|
document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`;
|
||||||
renderGutter();
|
renderGutter();
|
||||||
requestAnimationFrame(() => {
|
if (buildPreview) requestAnimationFrame(() => {
|
||||||
if (previewViewport) restorePreviewViewport(previewViewport);
|
if (previewViewport) restorePreviewViewport(previewViewport);
|
||||||
else syncPreviewScroll();
|
else syncPreviewScroll();
|
||||||
revealHeadingAnchor();
|
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 }); });
|
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 }); });
|
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 }); });
|
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 }); });
|
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 }); });
|
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 }); });
|
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("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 => {
|
preview.addEventListener("click", event => {
|
||||||
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
|
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
@@ -2170,6 +2282,10 @@ export function startNoteEditor(adapter) {
|
|||||||
let pdfExportBound = false;
|
let pdfExportBound = false;
|
||||||
let pdfExportLoad = null;
|
let pdfExportLoad = null;
|
||||||
pdfExportArgs.button?.addEventListener("click", async event => {
|
pdfExportArgs.button?.addEventListener("click", async event => {
|
||||||
|
if (previewStale) {
|
||||||
|
forcePreviewRender = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
if (pdfExportBound) return;
|
if (pdfExportBound) return;
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
pdfExportLoad ||= import("@rustpad/pdf-export").then(({ bindPdfExport }) => {
|
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" });
|
toast.info("The global profile color is active again.", { title: "Profile color restored" });
|
||||||
});
|
});
|
||||||
editor.addEventListener("keydown", continueIndentation);
|
editor.addEventListener("keydown", continueIndentation);
|
||||||
|
let editorScrollFrame = 0;
|
||||||
editor.addEventListener("scroll", () => {
|
editor.addEventListener("scroll", () => {
|
||||||
syncEditorLayers();
|
if (editorScrollFrame) return;
|
||||||
syncPreviewScroll();
|
editorScrollFrame = requestAnimationFrame(() => {
|
||||||
|
editorScrollFrame = 0;
|
||||||
|
syncEditorLayers();
|
||||||
|
syncPreviewScroll();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
editor.addEventListener("input", event => {
|
editor.addEventListener("input", event => {
|
||||||
if (!canEditDocument() && !applyingRemote) {
|
if (!canEditDocument() && !applyingRemote) {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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`);
|
||||||
Reference in new issue
Block a user