This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 13:07:22 +01:00
1 parent b15a6c54ce
commit d36a4b2451
22 files changed
+926 -114

No files matched your search

+132 -6
View File
@@ -37,7 +37,7 @@ import { syncSelectControl } from "@rustpad/select";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"), documentTitle = document.querySelector("#document-title");
const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), navbarCollapseToggle = document.querySelector("#navbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle");
@@ -309,13 +309,93 @@ export function startNoteEditor(adapter) {
render();
}
function syncInlineTitleRename() {
if (!documentTitle) return;
const enabled = Boolean(adapter.renameTitle && info?.can_rename);
documentTitle.classList.toggle("document-title--renamable", enabled);
documentTitle.closest("#document-link-copy")?.classList.toggle("document-heading--has-renamable-title", enabled);
if (enabled) documentTitle.title = t("editor.renameHint", {}, "Double-click to rename");
else documentTitle.removeAttribute("title");
}
function beginInlineTitleRename() {
if (!documentTitle || !adapter.renameTitle || !info?.can_rename || documentTitle.querySelector("input")) return;
const previous = String(info.title || documentTitle.textContent || "").trim();
const input = document.createElement("input");
input.className = "document-title-inline-input";
input.type = "text";
input.maxLength = 80;
input.value = previous;
input.setAttribute("aria-label", t("editor.renameLabel", {}, "Note name"));
documentTitle.replaceChildren(input);
documentTitle.classList.add("document-title--editing");
let finished = false;
const restore = title => {
documentTitle.classList.remove("document-title--editing");
documentTitle.textContent = title;
syncInlineTitleRename();
};
const finish = async save => {
if (finished) return;
finished = true;
const next = input.value.trim();
if (!save) { restore(previous); return; }
if (!next) {
restore(previous);
return;
}
if (next === previous) { restore(previous); return; }
input.disabled = true;
try {
const result = await adapter.renameTitle(sessionHeaders(), next);
info = { ...info, title: result.title || next, updated_at: result.updated_at || info.updated_at };
restore(info.title);
document.title = adapter.title(info);
toast.success(
t("editor.renamed", { title: info.title }, `Renamed to “${info.title}”.`),
{ title: t("editor.renamedTitle", {}, "Note renamed") },
);
} catch (error) {
restore(previous);
toast.danger(error.message, { title: t("editor.renameFailed", {}, "Could not rename note") });
}
};
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
void finish(true);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
void finish(false);
}
});
input.addEventListener("blur", () => { void finish(true); }, { once: true });
input.focus();
input.select();
}
documentTitle?.addEventListener("click", event => {
if (!info?.can_rename) return;
event.stopPropagation();
});
documentTitle?.addEventListener("dblclick", event => {
if (!info?.can_rename) return;
event.preventDefault();
event.stopPropagation();
beginInlineTitleRename();
});
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
const documentTitle = document.querySelector("#document-title");
if (documentTitle && info.title) {
documentTitle.removeAttribute("data-i18n");
documentTitle.textContent = info.title;
}
syncInlineTitleRename();
globalColor = info.global_color || ""; noteColor = info.note_color || "";
if (!getAuthToken()) noteColor = readGuestColor();
updateMarkdownFiles(info.files || []);
@@ -336,8 +416,7 @@ export function startNoteEditor(adapter) {
syncMobileEditorControls();
updateCurrentUser(); return info;
}
// 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 || {});
@@ -506,6 +585,7 @@ export function startNoteEditor(adapter) {
const renderVersion = ++mermaidRenderVersion;
const nodes = [...preview.querySelectorAll(".mermaid")];
if (!nodes.length) return;
preview.querySelectorAll(".mermaid-error").forEach(error => error.remove());
try {
nodes.forEach(node => {
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
@@ -513,10 +593,12 @@ export function startNoteEditor(adapter) {
const mermaid = await loadMermaid();
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
await mermaid.run({ nodes });
if (renderVersion === mermaidRenderVersion) preview.querySelectorAll(".mermaid-error").forEach(error => error.remove());
} catch {
if (renderVersion !== mermaidRenderVersion) return;
nodes.forEach(node => {
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
if (node.previousElementSibling?.classList.contains("mermaid-error")) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
@@ -552,8 +634,6 @@ export function startNoteEditor(adapter) {
reconcileChildren(participantBadges, nodes);
}
function syncOwnerLabels() {
// 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)`;
@@ -2268,6 +2348,7 @@ export function startNoteEditor(adapter) {
const documentLinkCopy = document.querySelector("#document-link-copy");
documentLinkCopy?.addEventListener("click", copyCurrentLink);
documentLinkCopy?.addEventListener("keydown", event => {
if (event.target instanceof Element && event.target.closest(".document-title--editing, input, textarea")) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copyCurrentLink();
@@ -2306,6 +2387,51 @@ export function startNoteEditor(adapter) {
renderFavoriteButton(false);
});
function ensurePdfFormatButtons() {
const menu = document.querySelector(".markdown-more-menu");
if (!menu) return;
const formats = [
["page-break", "editor.pageBreak", "Page break (PDF)"],
["pdf-header", "editor.pdfHeaderTag", "PDF header"],
["pdf-footer", "editor.pdfFooterTag", "PDF footer"],
["pdf-align-top", "editor.pdfAlignTopTag", "PDF align top"],
["pdf-align-center", "editor.pdfAlignCenterTag", "PDF align center"],
];
const tocButton = menu.querySelector('[data-format="toc"]');
let insertAfter = tocButton;
for (const [format, key, fallback] of formats) {
let button = menu.querySelector(`[data-format="${format}"]`);
if (!button) {
button = document.createElement("button");
button.type = "button";
button.dataset.format = format;
button.dataset.i18n = key;
button.textContent = t(key, {}, fallback);
if (format === "page-break") {
button.dataset.i18nTitle = "editor.pageBreakShortcut";
button.title = t("editor.pageBreakShortcut", {}, "Page break (PDF) · Ctrl/Cmd+Enter");
}
if (insertAfter?.parentNode === menu) insertAfter.after(button);
else menu.prepend(button);
}
insertAfter = button;
}
let separator = menu.querySelector(".markdown-more-separator");
if (!separator) {
separator = document.createElement("div");
separator.className = "markdown-more-separator";
separator.setAttribute("role", "separator");
separator.setAttribute("aria-hidden", "true");
}
menu.append(separator);
for (const [format] of formats) {
const button = menu.querySelector(`[data-format="${format}"]`);
if (button) menu.append(button);
}
}
ensurePdfFormatButtons();
let pendingPreviewFormatRange = null;
document.querySelectorAll("[data-format]").forEach(button => {
button.addEventListener("pointerdown", event => {