Files
racket-wiki/static/js/wiki.js
T

5412 lines
208 KiB
JavaScript

(() => {
"use strict";
/*
* racket-wiki browser application.
*
* The source is deliberately kept as one direct script instead of a client
* framework. Larger functions are documented with goal/pre/post/result-style
* comments, following the same readability rules as the Racket sources.
* Markdown source remains authoritative; Todo markup, WikiWords, namespace
* references and image options are expanded only for rendering.
*/
const state = {
session: null,
pages: [],
pageAliases: [],
currentPage: null,
editingNew: false,
newPageSlug: null,
previousView: "page-view",
translations: {},
translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki",
bookmarks: [],
breadcrumbTrail: [],
graphData: null,
contextDockPosition: null,
contextDockData: null,
conceptMaps: [],
cmapConceptUsage: new Map(),
cmapPageConcepts: new Map(),
currentConceptMap: null,
currentConceptMapSource: null,
cmapLoadSequence: 0,
cmapSavedSnapshot: null,
cmapGuardHash: "",
cmapPrototype: null,
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
};
let easyMDE = null;
let pendingCmapTransition = null;
let pendingWikiCmapLinkLabel = "";
let cmapStatusTimer = null;
let cmapAutosaveTimer = null;
let cmapSavePromise = null;
let cmapEmbedHydrationTimer = null;
const cmapDescriptionPreviewCache = new Map();
let cmapDescriptionTooltip = null;
let cmapDescriptionTooltipToken = 0;
const CMAP_AUTOSAVE_DELAY = 1500;
////////////////////////////////////////////////////////////////////////////////
// General UI and HTTP support
////////////////////////////////////////////////////////////////////////////////
const $ = (id) => document.getElementById(id);
const cmapMapCombobox = new window.RacketWikiComboBox($("cmap-map-combobox"));
const cmapPageCombobox = new window.RacketWikiComboBox($("cmap-concept-page-combobox"));
const cmapLinkCombobox = new window.RacketWikiComboBox($("cmap-concept-cmap-combobox"));
const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox"));
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
$("context-link").classList.toggle("hidden", !(state.currentPage && ["page-view", "editor-view", "history-view"].includes(viewId)));
document.body.classList.toggle("editor-mode", viewId === "editor-view");
document.body.classList.toggle("cmap-mode", viewId === "cmap-view");
}
function tr(key, fallback = key) {
return state.translations[key] || fallback;
}
function applyTranslations() {
document.querySelectorAll("[data-tr]").forEach((node) => {
node.textContent = tr(node.dataset.tr, node.textContent);
});
document.querySelectorAll("[data-tr-placeholder]").forEach((node) => {
node.placeholder = tr(node.dataset.trPlaceholder, node.placeholder);
});
document.querySelectorAll("[data-tr-title]").forEach((node) => {
node.title = tr(node.dataset.trTitle, node.title);
});
document.querySelectorAll("[data-tr-aria-label]").forEach((node) => {
node.setAttribute("aria-label", tr(node.dataset.trAriaLabel, node.getAttribute("aria-label")));
});
document.documentElement.lang = state.language || "en";
}
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/**
* goal : Split a wiki page reference into namespace and page slug.
* pre : reference is a string such as "roadmap" or "racket:roadmap".
* post : No application state is changed.
* result : An object with namespace and slug fields.
*/
function splitPageReference(reference) {
const value = String(reference || "");
const separator = value.indexOf(":");
if (separator < 0) return { namespace: "", slug: value };
return { namespace: value.slice(0, separator), slug: value.slice(separator + 1) };
}
/**
* goal : Build the compact external reference for a wiki page.
* pre : namespace and slug are already suitable wiki identifiers.
* post : No application state is changed.
* result : slug for root pages, otherwise namespace:slug.
*/
function pageReference(namespace, slug) {
const cleanNamespace = String(namespace || "").trim();
return cleanNamespace ? `${cleanNamespace}:${slug}` : slug;
}
/**
* goal : Convert a freely entered page title/address to a wiki reference.
* pre : input is the text from the CMap linked-page combobox.
* post : No page is created; the result can point at a missing page.
* result : A normalized root/namespaced reference, or null when empty/invalid.
*/
function newPageReference(input) {
const parts = splitPageReference(String(input || "").trim());
const slugify = (value, maximum) => String(value || "")
.normalize("NFKD")
.toLocaleLowerCase()
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, maximum)
.replace(/-+$/g, "");
const namespace = parts.namespace ? slugify(parts.namespace, 80) : "";
const slug = slugify(parts.slug, 120);
if (!slug || (parts.namespace && !namespace)) return null;
return pageReference(namespace, slug);
}
function pageRoute(reference) {
return `#/${encodeURIComponent(reference)}`;
}
function cmapRoute(slug) {
return `#cmap/${encodeURIComponent(slug)}`;
}
const CMAP_START_SLUG_KEY = "racket-wiki:start-cmap";
function startCmapSlug() {
try {
return window.localStorage.getItem(CMAP_START_SLUG_KEY) || "";
} catch (_error) {
return "";
}
}
function setStartCmapSlug(slug) {
try {
if (slug) window.localStorage.setItem(CMAP_START_SLUG_KEY, slug);
else window.localStorage.removeItem(CMAP_START_SLUG_KEY);
} catch (error) {
console.warn("The start CMap preference could not be stored.", error);
}
}
function navigateToHash(targetHash) {
const navigate = () => {
if (location.hash === targetHash) return route();
location.hash = targetHash;
return undefined;
};
return requestCmapTransition(navigate);
}
function namespaceLabel(namespace) {
return namespace || tr("root-namespace", "Root");
}
function canonicalPageReference(reference) {
const value = String(reference || "");
const key = value.toLocaleLowerCase();
const alias = state.pageAliases.find((item) => String(item.slug || "").toLocaleLowerCase() === key);
return alias ? alias.targetSlug : value;
}
function expandTodoMarkup(markdown, pageSlug = null) {
let inFence = false;
let todoNumber = 0;
return (markdown || "").split("\n").map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => {
todoNumber += 1;
const label = escapeHtml(tr("todo", "Todo"));
const todoText = escapeHtml(text.trim());
if (pageSlug) {
const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`;
return `<a class="wiki-todo" href="${href}"><strong>${label}:</strong> ${todoText}</a>`;
}
return `<span class="wiki-todo"><strong>${label}:</strong> ${todoText}</span>`;
});
}).join("\n");
}
function roleLevel(role) {
return { reader: 10, editor: 20, admin: 30 }[role] || 0;
}
function can(role) {
return state.session?.authenticated && roleLevel(state.session.user.role) >= roleLevel(role);
}
function updateRoleUi() {
document.querySelectorAll(".editor-only").forEach((node) => {
node.classList.toggle("hidden", !can("editor"));
});
document.querySelectorAll(".admin-only").forEach((node) => {
node.classList.toggle("hidden", !can("admin"));
});
}
/**
* goal : Perform one authenticated API request and decode its response.
* pre : path identifies a racket-wiki API route.
* post : CSRF and JSON headers are applied when needed; 401 redirects to login.
* result : Decoded JSON/text response or a thrown Error.
*/
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) {
headers.set("Content-Type", "application/json");
}
if (state.session?.csrfToken && !["GET", "HEAD"].includes((options.method || "GET").toUpperCase())) {
headers.set("X-CSRF-Token", state.session.csrfToken);
}
const response = await fetch(path, { ...options, headers });
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
if (response.status === 401) {
window.location.replace("/login");
}
const error = new Error(body?.error || body || `HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return body;
}
////////////////////////////////////////////////////////////////////////////////
// Markdown rendering and wiki-link syntax
////////////////////////////////////////////////////////////////////////////////
function applyImageWidthMarkup(html) {
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
const width = `${amount}${unit || "px"}`;
const closing = imageTag.endsWith("/>") ? "/>" : ">";
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
const classes = [];
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
if (effectiveFloatSide) {
classes.push(`wiki-image-float-${effectiveFloatSide}`);
} else if (align) {
classes.push(`wiki-image-${align}`);
}
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
` style=${quote}${value}; width: ${width}${quote}`);
} else {
tagBody += ` style="width: ${width}"`;
}
if (classes.length > 0) {
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
` class=${quote}${value} ${classes.join(" ")}${quote}`);
} else {
tagBody += ` class="${classes.join(" ")}"`;
}
}
return `${tagBody}${closing}`;
});
}
function normalizeMentionText(text) {
return String(text || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
}
function wikiWordParts(text) {
const value = String(text || "");
if (!/^(?:\p{Lu}\p{Ll}+){2,}$/u.test(value)) return [];
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
}
/**
* goal : Resolve a classic WikiWord to an existing page or a new page target.
* pre : text contains only the candidate WikiWord and aliases is built from state.pages.
* post : No page is created; this only creates a render target.
* result : {slug,title} or null when text is not a valid WikiWord.
*/
function camelCaseTarget(text, aliases, namespace = "") {
const parts = wikiWordParts(text);
if (parts.length === 0) return null;
const aliasKey = `${String(namespace || "").toLocaleLowerCase()}:${normalizeMentionText(text)}`;
const existing = aliases.get(aliasKey);
if (existing) {
return { slug: existing.slug, title: existing.title };
}
const pageSlug = parts.map((part) => part.toLocaleLowerCase()).join("-");
return {
slug: pageReference(namespace, pageSlug),
title: parts.join(" ")
};
}
function mentionAliases(page) {
const aliases = new Set();
const rawSlug = page.pageSlug || splitPageReference(page.slug).slug;
const values = [page.title, slugTitle(rawSlug), rawSlug.replaceAll("-", " ")];
for (const value of values) {
if (!value) continue;
aliases.add(value);
for (const wikiWord of value.match(/(?:\p{Lu}\p{Ll}+){2,}/gu) || []) {
aliases.add(wikiWord);
}
}
return Array.from(aliases)
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
.filter((alias) => alias.key.length >= 5);
}
/**
* goal : Build an ambiguity-aware lookup table for WikiWords.
* pre : state.pages contains the current page metadata.
* post : No state is changed.
* result : Map keys include the page namespace, so equal names may exist in different namespaces.
*/
function pageMentionMap(currentSlug = null) {
const map = new Map();
for (const page of state.pages) {
if (page.slug === currentSlug) continue;
const namespace = String(page.namespace || "").toLocaleLowerCase();
for (const alias of mentionAliases(page)) {
const namespacedKey = `${namespace}:${alias.key}`;
if (!map.has(namespacedKey)) {
map.set(namespacedKey, page);
} else if (map.get(namespacedKey)?.slug !== page.slug) {
map.set(namespacedKey, null);
}
const rootKey = `:${alias.key}`;
if (!map.has(rootKey)) {
map.set(rootKey, page);
} else if (map.get(rootKey)?.slug !== page.slug) {
map.set(rootKey, null);
}
}
}
for (const alias of state.pageAliases) {
const targetPage = state.pages.find((page) => page.slug === alias.targetSlug);
if (!targetPage || targetPage.slug === currentSlug) continue;
const namespace = String(alias.namespace || "").toLocaleLowerCase();
const aliasPage = { title: alias.title, pageSlug: alias.pageSlug, slug: alias.slug };
for (const candidate of mentionAliases(aliasPage)) {
const key = `${namespace}:${candidate.key}`;
if (!map.has(key)) map.set(key, targetPage);
const rootKey = `:${candidate.key}`;
if (!map.has(rootKey)) map.set(rootKey, targetPage);
}
}
return map;
}
/**
* goal : Resolve cmap:WikiWord against the stored concept-map catalogue.
* pre : state.conceptMaps contains the readable stored maps.
* post : No map is opened and no editor state is changed.
* result : The unique matching CMap or null for no/ambiguous matches.
*/
function cmapMentionTarget(text) {
const key = normalizeMentionText(text);
const matches = state.conceptMaps.filter((conceptMap) => {
const aliases = [conceptMap.title, conceptMap.slug, slugTitle(conceptMap.slug)];
return aliases.some((alias) => normalizeMentionText(alias) === key);
});
return matches.length === 1 ? matches[0] : null;
}
function markdownProtectedRanges(line) {
const ranges = [];
const patterns = [
/todo\([^()\n]*\)/gi,
/!?\[[^\]\n]*\]\([^\)\n]*\)/g,
/`+[^`\n]*`+/g,
/<https?:\/\/[^>\n]+>/gi,
/https?:\/\/[^\s<>()]+/gi
];
for (const pattern of patterns) {
for (const match of line.matchAll(pattern)) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
}
return ranges;
}
function positionIsProtected(start, end, ranges) {
for (const range of ranges) {
if (start < range.end && end > range.start) return true;
}
return false;
}
/**
* goal : Rewrite explicit Markdown targets such as (racket:roadmap) to the internal hash route.
* pre : markdown is source text; fenced code must remain untouched.
* post : Only explicit wiki-looking link destinations are rewritten for rendering.
* result : Render-only Markdown; stored Markdown is never changed.
*/
function expandNamespacedMarkdownLinks(markdown) {
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
fence = fence === null ? marker : (fence === marker ? null : fence);
result.push(line);
continue;
}
if (fence !== null || /^\s{4}/.test(line)) {
result.push(line);
continue;
}
result.push(line.replace(/(!?\[[^\]\n]*\]\()([\p{L}\p{N}._-]+):([\p{L}\p{N}._-]+)(\))/gu,
(_match, before, namespace, slug, after) => {
const target = namespace.toLocaleLowerCase() === "cmap" ?
cmapRoute(slug) : pageRoute(pageReference(namespace, slug));
return `${before}${target}${after}`;
}));
}
return result.join("\n");
}
/**
* goal : Expand classic WikiWords to temporary Markdown links before Marked renders them.
* pre : markdown may contain paragraphs, lists, tables, quotes and headings.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
* result : Render-only Markdown with WikiWord links.
*/
function expandWikiMentions(markdown, currentSlug = null) {
const aliases = pageMentionMap(currentSlug);
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
if (fence === null) {
fence = marker;
} else if (fence === marker) {
fence = null;
}
result.push(line);
continue;
}
if (fence !== null || /^\s{4}/.test(line)) {
result.push(line);
continue;
}
const protectedRanges = markdownProtectedRanges(line);
const replacements = [];
for (const match of line.matchAll(wikiWordPattern)) {
const start = match.index;
const end = start + match[0].length;
if (positionIsProtected(start, end, protectedRanges)) continue;
const namespace = match[1] || "";
if (namespace.toLocaleLowerCase() === "cmap") {
const conceptMap = cmapMentionTarget(match[2]);
if (conceptMap) replacements.push({ start, end, conceptMap });
} else {
const page = camelCaseTarget(match[2], aliases, namespace);
if (page) replacements.push({ start, end, page });
}
}
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = replacement.conceptMap ?
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
}
result.push(expanded);
}
return result.join("\n");
}
/**
* goal : Render wiki Markdown using the same EasyMDE/Marked pipeline everywhere.
* pre : markdown is source text; pageSlug is optional context for Todo links.
* post : The returned HTML is sanitized with DOMPurify.
* result : Safe HTML for preview, reader view or history view.
*/
function extractCmapEmbeds(markdown) {
const embeds = [];
let fence = null;
const lines = String(markdown || "").split("\n").map((line) => {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
return line;
}
if (fence !== null) return line;
const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i);
if (!match) return line;
const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`;
embeds.push({ token, reference: match[1].trim() });
return token;
});
return { markdown: lines.join("\n"), embeds };
}
function restoreCmapEmbeds(html, embeds) {
let result = html;
for (const embed of embeds) {
const placeholder = `<section class="rw-cmap-embed" data-cmap-reference="${escapeHtml(embed.reference)}"><div class="rw-cmap-embed-loading">${escapeHtml(tr("loading", "Loading…"))}</div></section>`;
result = result.replace(`<p>${embed.token}</p>`, placeholder).replace(embed.token, placeholder);
}
return result;
}
function queueCmapEmbedHydration() {
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
cmapEmbedHydrationTimer = window.setTimeout(() => {
cmapEmbedHydrationTimer = null;
hydrateCmapEmbeds(document).catch((error) => console.error(error));
}, 0);
}
function renderMarkdown(markdown, pageSlug = null) {
const extracted = extractCmapEmbeds(markdown || "");
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(extracted.markdown);
const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug);
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
const html = easyMDE.markdown(withTodos);
const withEmbeds = restoreCmapEmbeds(html, extracted.embeds);
const withImages = applyImageWidthMarkup(withEmbeds);
const safeHtml = DOMPurify.sanitize(withImages);
if (extracted.embeds.length) queueCmapEmbedHydration();
return safeHtml;
}
async function hydrateCmapEmbeds(root) {
const embeds = Array.from(root.querySelectorAll(".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = cmapMentionTarget(embed.dataset.cmapReference || "");
if (!conceptMap) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = tr("concept-map-not-found", "CMap not found");
embed.append(message);
continue;
}
try {
const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`);
const documentValue = decodeStoredConceptMapDocument(stored);
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
const header = document.createElement("header");
const title = document.createElement("strong");
title.textContent = conceptMap.title;
const hint = document.createElement("span");
hint.textContent = tr("embedded-concept-map-help", "Double-click to open this CMap.");
header.append(title, hint);
const viewport = document.createElement("div");
viewport.className = "rw-cmap-embed-viewport";
const canvas = document.createElement("div");
canvas.className = "cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
viewport.append(canvas);
embed.append(header, viewport);
const editor = window.RacketWikiCmap.createEditor(canvas, {
Cmap: window.Cmap,
renderItem: (record) => cmapNodeHtml(record)
});
editor.loadDocument(documentValue);
const visible = editor.items.filter((item) => editor.isEffectiveItemVisible(item));
if (visible.length) {
const left = Math.min(...visible.map((item) => Number(item.node.attr("x")))) - 24;
const top = Math.min(...visible.map((item) => Number(item.node.attr("y")))) - 24;
const right = Math.max(...visible.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
const bottom = Math.max(...visible.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
const availableWidth = Math.max(320, embed.clientWidth - 2);
const scale = Math.min(1, availableWidth / Math.max(1, right - left), 520 / Math.max(1, bottom - top));
editor.zoomFactor = scale;
editor.map.zoom(scale);
viewport.style.height = `${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
window.requestAnimationFrame(() => {
viewport.scrollLeft = Math.max(0, left * scale);
viewport.scrollTop = Math.max(0, top * scale);
});
}
embed.dataset.cmapHydrated = "ready";
embed.addEventListener("dblclick", () => {
navigateToHash(cmapRoute(conceptMap.slug)).catch((error) => console.error(error));
});
} catch (error) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = error.message;
embed.append(message);
}
}
}
function slugTitle(slug) {
const pageSlug = splitPageReference(slug || "").slug;
return pageSlug
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
////////////////////////////////////////////////////////////////////////////////
// Page display and navigation
////////////////////////////////////////////////////////////////////////////////
function setPageActionVisibility(pageExists) {
if (!can("editor")) return;
$("edit-page").classList.remove("hidden");
$("rename-page").classList.toggle("hidden", !pageExists || state.currentPage?.slug === state.translationPage);
$("delete-page").classList.toggle("hidden", !pageExists);
$("history-page").classList.toggle("hidden", !pageExists);
}
function pageDisplayDate(seconds) {
return new Date(seconds * 1000).toLocaleString();
}
function parseTags(text) {
const seen = new Set();
const tags = [];
for (const part of (text || "").split(",")) {
const tag = part.trim();
const key = tag.toLocaleLowerCase();
if (tag && !seen.has(key)) {
seen.add(key);
tags.push(tag);
}
}
return tags;
}
function renderPageDetails(page) {
const details = $("page-details");
details.replaceChildren();
const fields = [];
if (page.namespace) {
fields.push(`${tr("namespace", "Namespace")}: ${page.namespace}`);
}
fields.push(
`${tr("created", "Created")} ${pageDisplayDate(page.createdAt)} ${tr("by", "by")} ${page.createdBy}`,
`${tr("modified", "Modified")} ${pageDisplayDate(page.updatedAt)} ${tr("by", "by")} ${page.updatedBy}`,
`${tr("version", "Version")} ${page.currentVersion}`
);
for (const text of fields) {
const span = document.createElement("span");
span.textContent = text;
details.append(span);
}
const tags = Array.isArray(page.tags) ? page.tags : [];
const tagField = document.createElement("span");
tagField.className = "page-tags";
if (tags.length === 0) {
tagField.textContent = tr("tags-none", "Tags: none");
} else {
const label = document.createTextNode(tr("tags-label", "Tags: "));
tagField.append(label);
tags.forEach((tag, index) => {
if (index > 0) tagField.append(document.createTextNode(", "));
const value = document.createElement("span");
value.className = "page-tag";
value.textContent = tag;
tagField.append(value);
});
}
details.append(tagField);
}
function renderPageCmapConnections(page) {
const section = $("page-cmap-connections");
const list = $("page-cmap-connections-list");
const pageKey = canonicalPageReference(page.slug).toLocaleLowerCase();
const concepts = [...(state.cmapPageConcepts.get(pageKey)?.values() || [])]
.sort((first, second) => first.label.localeCompare(second.label));
list.replaceChildren();
section.classList.toggle("hidden", concepts.length === 0);
if (!concepts.length) return;
for (const concept of concepts) {
const row = document.createElement("div");
row.className = "page-cmap-concept-row";
const description = document.createElement("div");
description.className = "page-cmap-concept-description";
const label = document.createElement("strong");
label.textContent = concept.label;
const usage = document.createElement("span");
usage.className = "muted";
usage.textContent = tr("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(concept.count));
description.append(label, usage);
const maps = document.createElement("nav");
maps.className = "page-cmap-concept-maps";
maps.setAttribute("aria-label", tr("concept-map-locations", "Concept map locations"));
for (const placement of [...concept.maps.values()]
.sort((first, second) => first.title.localeCompare(second.title))) {
const link = document.createElement("a");
link.href = cmapRoute(placement.slug);
link.textContent = placement.count > 1 ?
`${placement.title} (${placement.count})` : placement.title;
maps.append(link);
}
row.append(description, maps);
list.append(row);
}
}
function wikiSlugFromHref(href) {
if (!href) return null;
let candidate = null;
if (href.startsWith("#/") && !href.includes("?")) {
candidate = href.slice(2);
} else if (href.startsWith("/") && href.indexOf("/", 1) === -1 && !href.includes("?") && !href.includes("#")) {
candidate = href.slice(1);
} else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) {
candidate = href.slice(2);
} else if (!href.includes("/") && !href.includes("#") && !href.includes("?")) {
candidate = href;
}
if (!candidate) return null;
const reserved = new Set(["api", "uploads", "setup", "login", "vendor", "css", "js", "index.html"]);
let decoded;
try {
decoded = decodeURIComponent(candidate);
} catch (_error) {
return null;
}
return reserved.has(decoded) ? null : decoded;
}
function cmapSlugFromHref(href) {
if (!href) return null;
const candidate = href.startsWith("#cmap/") ? href.slice(6) :
(href.toLocaleLowerCase().startsWith("cmap:") ? href.slice(5) : null);
if (!candidate || candidate.includes("/") || candidate.includes("?") || candidate.includes("#")) return null;
try {
return decodeURIComponent(candidate);
} catch (_error) {
return null;
}
}
function installWikiLinkNavigation() {
$("markdown-preview").addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const cmapSlug = cmapSlugFromHref(link.getAttribute("href"));
if (cmapSlug) {
event.preventDefault();
location.hash = cmapRoute(cmapSlug);
return;
}
const slug = wikiSlugFromHref(link.getAttribute("href"));
if (!slug) return;
event.preventDefault();
location.hash = `#/${encodeURIComponent(slug)}`;
});
}
const editorToolbarIcons = new Map();
function toolbarButton(name, action, icon, title, options = {}) {
editorToolbarIcons.set(name, icon);
return {
name,
action,
className: `rw-mde-button rw-mde-${name}`,
title,
...options
};
}
function installLucideIcons() {
if (!window.lucide || typeof window.lucide.createIcons !== "function") {
throw new Error("Lucide is not installed. Open /setup to repair the frontend setup.");
}
for (const [name, iconName] of editorToolbarIcons) {
const button = document.querySelector(`.EasyMDEContainer .editor-toolbar .rw-mde-${name}`);
if (!button) continue;
const icon = document.createElement("i");
icon.dataset.lucide = iconName;
icon.setAttribute("aria-hidden", "true");
button.replaceChildren(icon);
}
window.lucide.createIcons();
}
////////////////////////////////////////////////////////////////////////////////
// EasyMDE editor setup and editing support
////////////////////////////////////////////////////////////////////////////////
function initializeHighlighting() {
if (!window.hljs) return;
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
window.hljs.registerAliases(["racket"], { languageName: "scheme" });
}
}
function applyRawMarkdownMode() {
if (!easyMDE) return;
editorContainer().classList.toggle("raw-markdown", state.rawMarkdown);
}
function toggleRawMarkdown() {
state.rawMarkdown = !state.rawMarkdown;
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
applyRawMarkdownMode();
easyMDE.codemirror.refresh();
}
/**
* goal : Configure the single EasyMDE instance used for page editing.
* pre : Vendor scripts and the editor textarea are loaded.
* post : easyMDE is ready with toolbar, uploads, Raw mode and live TOC updates.
*/
function initializeEditor() {
if (!window.EasyMDE) {
throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup.");
}
if (!window.DOMPurify) {
throw new Error("DOMPurify is not installed. Open /setup to repair the frontend setup.");
}
initializeHighlighting();
easyMDE = new EasyMDE({
element: $("markdown-editor"),
autoDownloadFontAwesome: false,
autoRefresh: { delay: 200 },
forceSync: true,
lineNumbers: true,
lineWrapping: true,
indentWithTabs: false,
tabSize: 2,
minHeight: "520px",
nativeSpellcheck: true,
spellChecker: false,
previewImagesInEditor: true,
previewClass: ["editor-preview", "markdown-body", "preview-pane"],
sideBySideFullscreen: false,
syncSideBySidePreviewScroll: true,
status: ["lines", "words", "cursor"],
uploadImage: true,
imageMaxSize: 50 * 1024 * 1024,
imageAccept: "image/png,image/jpeg,image/gif,image/webp",
imageUploadFunction: (file, onSuccess, onError) => {
uploadImageForEasyMDE(file)
.then(onSuccess)
.catch((error) => onError(error.message));
},
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText, state.currentPage?.slug || state.newPageSlug) : "",
renderingConfig: {
codeSyntaxHighlighting: true,
hljs: window.hljs,
sanitizerFunction: (html) => DOMPurify.sanitize(html)
},
toolbar: [
toolbarButton("bold", EasyMDE.toggleBold, "bold", tr("bold", "Bold")),
toolbarButton("italic", EasyMDE.toggleItalic, "italic", tr("italic", "Italic")),
toolbarButton("strikethrough", EasyMDE.toggleStrikethrough, "strikethrough", tr("strikethrough", "Strikethrough")),
toolbarButton("heading", EasyMDE.toggleHeadingSmaller, "heading", tr("heading", "Heading")),
"|",
toolbarButton("quote", EasyMDE.toggleBlockquote, "quote", tr("quote", "Quote")),
toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list", tr("bulleted-list", "Bulleted list")),
toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ordered", tr("numbered-list", "Numbered list")),
toolbarButton("check-list", EasyMDE.toggleCheckList, "list-checks", tr("checklist", "Checklist")),
toolbarButton("code", EasyMDE.toggleCodeBlock, "code", tr("code-block", "Code block")),
toolbarButton("table", EasyMDE.drawTable, "table", tr("table", "Table")),
"|",
toolbarButton("link", EasyMDE.drawLink, "link", tr("link", "Link")),
toolbarButton("cmap-link", () => {
openWikiCmapLinkDialog().catch((error) => {
$("save-status").textContent = error.message;
console.error(error);
});
}, "share-2", tr("link-concept-map", "Link to CMap")),
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", tr("upload-image", "Upload image")),
toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")),
toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")),
"|",
toolbarButton("undo", EasyMDE.undo, "undo-2", tr("undo", "Undo")),
toolbarButton("redo", EasyMDE.redo, "redo-2", tr("redo", "Redo")),
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns-2", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "maximize", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
]
});
installLucideIcons();
applyRawMarkdownMode();
easyMDE.codemirror.on("change", () => {
if (!$("editor-view").classList.contains("hidden")) {
renderEditorToc();
}
});
installGeneralFileDrop();
window.addEventListener("resize", () => {
if (!$("editor-view").classList.contains("hidden")) {
updateEditorChromeMetrics();
easyMDE.codemirror.refresh();
}
});
}
function editorContainer() {
return easyMDE.codemirror.getWrapperElement().closest(".EasyMDEContainer");
}
function updateEditorChromeMetrics() {
if (!easyMDE) return;
const container = editorContainer();
const toolbar = container.querySelector(".editor-toolbar");
const statusbar = container.querySelector(".editor-statusbar");
const toolbarHeight = toolbar ? toolbar.offsetHeight : 0;
const statusHeight = statusbar ? statusbar.offsetHeight : 0;
container.style.setProperty("--editor-toolbar-height", `${toolbarHeight}px`);
container.style.setProperty("--editor-status-height", `${statusHeight}px`);
}
/**
* goal : Show the editor and synchronize its sticky chrome and TOC.
* pre : easyMDE has been initialized.
* post : Editor view is visible and sized for the current viewport.
*/
function activateEditor() {
show("editor-view");
renderEditorToc();
requestAnimationFrame(() => {
easyMDE.codemirror.refresh();
const wideScreen = window.matchMedia("(min-width: 901px)").matches;
if (wideScreen && !easyMDE.isSideBySideActive()) {
easyMDE.toggleSideBySide();
}
updateEditorChromeMetrics();
easyMDE.codemirror.refresh();
});
}
function headingId(text, usedIds) {
const base = text
.trim()
.toLocaleLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "") || "section";
let id = base;
let number = 2;
while (usedIds.has(id)) {
id = `${base}-${number}`;
number += 1;
}
usedIds.add(id);
return id;
}
function markdownHeadings(markdown) {
const headings = [];
let inFence = false;
const fencePattern = /^\s*(```|~~~)/;
(markdown || "").replace(/\r\n/g, "\n").split("\n").forEach((line, lineNumber) => {
if (fencePattern.test(line)) {
inFence = !inFence;
return;
}
if (inFence) return;
const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!match) return;
headings.push({
level: match[1].length,
text: match[2].trim(),
line: lineNumber
});
});
return headings;
}
function renderToc(entries, onSelect, onEdit = null) {
const toc = $("toc-list");
toc.replaceChildren();
if (entries.length === 0) {
const empty = document.createElement("div");
empty.className = "toc-empty";
empty.textContent = tr("no-headings", "No headings");
toc.append(empty);
return;
}
for (const entry of entries) {
const row = document.createElement("div");
row.className = "toc-row";
const link = document.createElement("a");
link.href = entry.href || "#";
link.className = `toc-link toc-level-${Math.min(entry.level, 4)}`;
link.textContent = entry.text;
link.addEventListener("click", (event) => {
event.preventDefault();
onSelect(entry);
});
row.append(link);
if (onEdit) {
const edit = document.createElement("a");
edit.href = "#";
edit.className = "toc-edit-link";
edit.textContent = "✎";
edit.title = tr("edit-section", "Edit section");
edit.setAttribute("aria-label", `${tr("edit-section", "Edit section")}: ${entry.text}`);
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onEdit(entry);
});
row.append(edit);
}
toc.append(row);
}
}
/**
* goal : Build the reader TOC from rendered headings and Markdown source lines.
* pre : state.currentPage and #markdown-preview represent the same page.
* post : TOC navigation scrolls below sticky chrome; editors get section-edit links.
*/
function renderPageToc() {
const article = $("markdown-preview");
const usedIds = new Set();
const sourceHeadings = markdownHeadings(state.currentPage?.markdown || "");
const entries = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading, index) => {
const text = heading.textContent.trim();
const id = heading.id || headingId(text, usedIds);
heading.id = id;
return {
level: Number(heading.tagName.slice(1)),
text,
href: `#${id}`,
element: heading,
line: sourceHeadings[index]?.line ?? null
};
});
renderToc(
entries,
(entry) => {
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
},
can("editor")
? (entry) => beginEditPageAtLine(entry.line)
: null
);
}
function renderEditorToc() {
if (!easyMDE) return;
const entries = markdownHeadings(easyMDE.value());
renderToc(entries, (entry) => {
easyMDE.codemirror.setCursor({ line: entry.line, ch: 0 });
easyMDE.codemirror.scrollIntoView({ line: entry.line, ch: 0 }, 120);
easyMDE.codemirror.focus();
});
}
let highlightedEditorLine = null;
let highlightedEditorTimer = null;
function clearEditorLineHighlight() {
if (!easyMDE || highlightedEditorLine === null) return;
easyMDE.codemirror.removeLineClass(highlightedEditorLine, "background", "section-edit-highlight");
highlightedEditorLine = null;
if (highlightedEditorTimer) {
window.clearTimeout(highlightedEditorTimer);
highlightedEditorTimer = null;
}
}
function focusEditorLine(line) {
if (!easyMDE || line === null || line === undefined) return;
clearEditorLineHighlight();
easyMDE.codemirror.setCursor({ line, ch: 0 });
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
highlightedEditorLine = easyMDE.codemirror.addLineClass(line, "background", "section-edit-highlight");
highlightedEditorTimer = window.setTimeout(clearEditorLineHighlight, 4500);
easyMDE.codemirror.focus();
}
function beginEditPageAtLine(line) {
beginEditPage();
requestAnimationFrame(() => {
requestAnimationFrame(() => focusEditorLine(line));
});
}
const breadcrumbStorageKey = "racket-wiki-breadcrumb-trail";
const breadcrumbTrailLimit = 8;
function saveBreadcrumbTrail() {
window.sessionStorage.setItem(breadcrumbStorageKey, JSON.stringify(state.breadcrumbTrail));
}
function loadBreadcrumbTrail() {
let stored = [];
try {
stored = JSON.parse(window.sessionStorage.getItem(breadcrumbStorageKey) || "[]");
} catch (_error) {
stored = [];
}
const pageSlugs = new Set(state.pages.map((page) => page.slug));
state.breadcrumbTrail = Array.isArray(stored)
? stored.filter((slug) => typeof slug === "string" && pageSlugs.has(slug))
: [];
saveBreadcrumbTrail();
}
function clearBreadcrumbTrail() {
state.breadcrumbTrail = [];
saveBreadcrumbTrail();
}
function recordPageVisit(page) {
if (!page) return;
const firstPage = startPage();
if (firstPage && page.slug === firstPage.slug) {
clearBreadcrumbTrail();
return;
}
const existingIndex = state.breadcrumbTrail.indexOf(page.slug);
if (existingIndex >= 0) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, existingIndex + 1);
} else {
state.breadcrumbTrail.push(page.slug);
if (state.breadcrumbTrail.length > breadcrumbTrailLimit) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(-breadcrumbTrailLimit);
}
}
saveBreadcrumbTrail();
}
function truncateBreadcrumbTrail(slug) {
const firstPage = startPage();
if (firstPage && slug === firstPage.slug) {
clearBreadcrumbTrail();
return;
}
const index = state.breadcrumbTrail.indexOf(slug);
if (index >= 0) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, index + 1);
saveBreadcrumbTrail();
}
}
/**
* goal : Render the sticky history breadcrumb as real navigation links.
* pre : items are ordered from wiki home to the current context.
* post : #breadcrumbs contains clickable previous page locations.
*/
function renderBreadcrumbs(items) {
const breadcrumbs = $("breadcrumbs");
breadcrumbs.replaceChildren();
if (!items || items.length === 0) {
breadcrumbs.classList.add("hidden");
return;
}
breadcrumbs.classList.remove("hidden");
const label = document.createElement("span");
label.className = "breadcrumb-label";
label.textContent = `${tr("you-are-here", "You are here")}:`;
breadcrumbs.append(label);
items.forEach((item, index) => {
if (index > 0) {
const separator = document.createElement("span");
separator.className = "breadcrumb-separator";
separator.textContent = "»";
breadcrumbs.append(separator);
}
if (item.href) {
const link = document.createElement("a");
link.href = item.href;
if (item.home || item.href === "/") link.dataset.home = "true";
if (item.slug) link.dataset.breadcrumbSlug = item.slug;
link.textContent = item.label;
breadcrumbs.append(link);
} else {
const current = document.createElement("span");
current.className = "breadcrumb-current";
current.textContent = item.label;
breadcrumbs.append(current);
}
});
}
/**
* goal : Build the page breadcrumb from the per-tab visit history.
* pre : state.pages and breadcrumbTrail are current.
* post : The sticky breadcrumb shows Home, previous pages and optional suffix.
*/
function pageBreadcrumbs(page, suffix = null) {
const firstPage = startPage();
const items = [];
if (firstPage) {
items.push({
label: state.siteTitle,
href: `#/${encodeURIComponent(firstPage.slug)}`,
slug: firstPage.slug,
home: true
});
} else {
items.push({ label: state.siteTitle });
}
for (const slug of state.breadcrumbTrail) {
const trailPage = state.pages.find((item) => item.slug === slug);
if (!trailPage) continue;
const isCurrent = page && trailPage.slug === page.slug && !suffix;
items.push({
label: trailPage.title,
href: isCurrent ? null : `#/${encodeURIComponent(trailPage.slug)}`,
slug: trailPage.slug
});
}
if (page && firstPage && page.slug === firstPage.slug && suffix) {
items[0].href = `#/${encodeURIComponent(firstPage.slug)}`;
}
if (suffix) {
items.push({ label: suffix });
}
renderBreadcrumbs(items);
}
function startPage() {
const namedStartPage = state.pages.find((page) => page.slug === "start") || null;
if (namedStartPage) return namedStartPage;
if (state.pages.length === 0) return null;
return state.pages.reduce((first, page) => {
if (!first) return page;
return Number(page.createdAt) < Number(first.createdAt) ? page : first;
}, null);
}
function updateWikiIdentity() {
const firstPage = startPage();
state.siteTitle = firstPage?.title || "Racket Wiki";
$("wiki-brand").textContent = state.siteTitle;
$("wiki-brand").href = firstPage ? `#/${encodeURIComponent(firstPage.slug)}` : "#";
document.title = state.siteTitle;
}
/**
* goal : Return to the first/start page from every normal or special view.
* pre : Page metadata has been loaded.
* post : Breadcrumb history is cleared and the start page is opened.
*/
async function goHome() {
const firstPage = startPage();
clearBreadcrumbTrail();
if (!firstPage) {
await route();
return;
}
const targetHash = `#/${encodeURIComponent(firstPage.slug)}`;
if (location.hash === targetHash) {
await openPage(firstPage.slug);
} else {
location.hash = targetHash;
}
}
/**
* goal : Render the secondary page list grouped by namespace.
* pre : state.pages contains current page metadata.
* post : #page-list reflects the current page set.
*/
function renderPageList() {
const list = $("page-list");
list.replaceChildren();
let previousNamespace = null;
for (const page of state.pages) {
const namespace = page.namespace || "";
if (namespace !== previousNamespace) {
const heading = document.createElement("div");
heading.className = "namespace-heading";
heading.textContent = namespaceLabel(namespace);
list.append(heading);
previousNamespace = namespace;
}
const link = document.createElement("a");
link.href = pageRoute(page.slug);
link.className = "page-link";
link.textContent = page.title;
link.classList.toggle("active", state.currentPage?.slug === page.slug);
list.append(link);
}
}
/**
* goal : Refresh current page metadata from PostgreSQL through the API.
* pre : The user is authenticated.
* post : state.pages, site identity, page list and template list are refreshed.
*/
async function loadPages() {
const result = await api("/api/pages");
state.pages = result.pages;
state.pageAliases = result.aliases || [];
cmapDescriptionPreviewCache.clear();
state.graphData = null;
updateWikiIdentity();
renderPageList();
updateTemplateSelect();
}
function templatePages() {
return state.pages.filter((page) => (page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase().startsWith("template-"));
}
function updateTemplateSelect() {
const select = $("template-select");
const selected = select.value;
select.replaceChildren();
const none = document.createElement("option");
none.value = "";
none.textContent = tr("no-template", "No template");
select.append(none);
for (const page of templatePages()) {
const option = document.createElement("option");
option.value = page.slug;
option.textContent = page.title;
select.append(option);
}
select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : "";
}
/**
* goal : Replace editor Markdown with a selected template-* wiki page.
* pre : slug identifies a readable template page.
* post : Editor contents are replaced after confirmation when necessary.
*/
async function applyTemplate(slug) {
if (!slug) return;
const template = await api(`/api/pages/${encodeURIComponent(slug)}`);
const current = easyMDE.value();
if (current.trim() !== "") {
const message = tr("replace-with-template", "Replace the current page content with template {template}?")
.replace("{template}", template.title);
if (!window.confirm(message)) {
$("template-select").value = "";
return;
}
}
easyMDE.value(template.markdown || "");
$("template-select").value = "";
renderEditorToc();
easyMDE.codemirror.focus();
}
function bookmarkForSlug(slug) {
return state.bookmarks.find((bookmark) => bookmark.slug === slug) || null;
}
function updateBookmarkAction() {
const link = $("bookmark-page");
if (!state.currentPage) {
link.classList.add("hidden");
return;
}
link.classList.remove("hidden");
const bookmark = bookmarkForSlug(state.currentPage.slug);
link.textContent = bookmark ? tr("bookmarked", "Bookmarked") : tr("bookmark", "Bookmark");
}
async function loadBookmarks() {
const result = await api("/api/bookmarks");
state.bookmarks = result.bookmarks || [];
updateBookmarkAction();
}
/**
* goal : Open one page in reader mode.
* pre : slug is a root or namespace-qualified page reference.
* post : Current page state, breadcrumb, TOC and bookmark action are updated.
*/
async function openPage(slug) {
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
state.currentPage = page;
if (slug !== page.slug) {
history.replaceState(null, "", pageRoute(page.slug));
}
recordPageVisit(page);
state.editingNew = false;
state.newPageSlug = null;
$("page-title").textContent = page.title;
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug);
renderPageDetails(page);
renderPageCmapConnections(page);
pageBreadcrumbs(page);
show("page-view");
setPageActionVisibility(true);
renderPageToc();
renderPageList();
updateBookmarkAction();
if (state.contextDockPosition) {
refreshContextDock().catch((error) => console.error(error));
}
}
function updateEditorSlugInfo() {
const namespace = $("editor-namespace")?.value.trim() || "";
if (!state.editingNew && state.currentPage) {
const rawSlug = state.currentPage.pageSlug || splitPageReference(state.currentPage.slug).slug;
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace, rawSlug)}`;
return;
}
if (state.newPageSlug) {
const requested = splitPageReference(state.newPageSlug);
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace || requested.namespace, requested.slug)}`;
return;
}
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
}
/**
* goal : Open an empty editor for a not-yet-existing page reference.
* pre : requestedSlug is null or a compact root/namespaced page reference.
* post : Namespace, title, template and Markdown controls are initialized for creation.
*/
function beginNewPage(requestedSlug = null) {
state.editingNew = true;
state.currentPage = null;
state.newPageSlug = requestedSlug;
const translationPage = requestedSlug && requestedSlug === state.translationPage;
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
$("editor-namespace").disabled = Boolean(translationPage);
$("editor-tags").value = "";
if (translationPage) {
easyMDE.value(state.translationTemplate || "");
} else {
easyMDE.value("");
}
$("edit-summary").value = "";
$("save-status").textContent = "";
$("template-select").value = "";
updateEditorSlugInfo();
activateEditor();
$("editor-title").focus();
}
/**
* goal : Open the current page in EasyMDE.
* pre : state.currentPage is a current page, or newPageSlug identifies a missing page.
* post : Editor fields contain the page title, namespace, tags and Markdown.
*/
function beginEditPage() {
if (!state.currentPage) {
if (state.newPageSlug) {
beginNewPage(state.newPageSlug);
}
return;
}
state.editingNew = false;
state.newPageSlug = null;
$("editor-title").value = state.currentPage.title;
$("editor-namespace").value = state.currentPage.namespace || "";
// Existing page addresses are changed only through Rename so an alias can be retained.
$("editor-namespace").disabled = true;
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
easyMDE.value(state.currentPage.markdown);
$("edit-summary").value = "";
$("save-status").textContent = "";
$("template-select").value = "";
updateEditorSlugInfo();
activateEditor();
}
/**
* goal : Save the current editor contents as a new or updated wiki page.
* pre : EasyMDE is active and title/namespace fields contain editor input.
* post : A successful save refreshes page metadata and opens the stored page.
*/
async function savePage() {
const title = $("editor-title").value.trim();
const namespace = $("editor-namespace").value.trim();
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
const markdown = easyMDE.value();
const tags = parseTags($("editor-tags").value);
const summary = $("edit-summary").value.trim();
$("save-status").textContent = tr("saving", "Saving…");
try {
let page;
if (state.editingNew) {
const body = {
title,
namespace,
markdown,
tags,
summary: summary || tr("created-page", "Created page")
};
if (state.newPageSlug) {
const requested = splitPageReference(state.newPageSlug);
body.slug = pageReference(namespace || requested.namespace, requested.slug);
}
page = await api("/api/pages", {
method: "POST",
body: JSON.stringify(body)
});
} else {
const slug = state.currentPage.slug;
page = await api(`/api/pages/${encodeURIComponent(slug)}`, {
method: "PUT",
body: JSON.stringify({
title,
namespace,
markdown,
tags,
baseVersion: state.currentPage.currentVersion,
summary: summary || tr("edited-page", "Edited page")
})
});
}
state.currentPage = page;
state.editingNew = false;
state.newPageSlug = null;
await loadPages();
if (page.slug === state.translationPage) {
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationTemplate = translationData.template || state.translationTemplate;
state.translations = translationData.translations || {};
applyTranslations();
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
}
location.hash = `#/${encodeURIComponent(page.slug)}`;
await openPage(page.slug);
$("save-status").textContent = tr("saved", "Saved");
} catch (error) {
$("save-status").textContent = error.message;
}
}
function insertTextAtCursor(text) {
const doc = easyMDE.codemirror.getDoc();
doc.replaceSelection(text, "end");
easyMDE.codemirror.focus();
}
function escapeMarkdownLinkLabel(text) {
return String(text || "")
.replaceAll("\\", "\\\\")
.replaceAll("[", "\\[")
.replaceAll("]", "\\]");
}
async function openWikiCmapLinkDialog() {
pendingWikiCmapLinkLabel = easyMDE.codemirror.getDoc().getSelection();
await loadConceptMaps();
wikiCmapLinkCombobox.setOptions(
state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)), "");
$("wiki-cmap-link-submit").disabled = state.conceptMaps.length === 0;
$("wiki-cmap-embed-submit").disabled = state.conceptMaps.length === 0;
$("wiki-cmap-link").placeholder = state.conceptMaps.length ?
tr("filter-concept-maps", "Filter concept maps") :
tr("no-concept-maps", "No saved CMaps");
$("wiki-cmap-link-dialog").showModal();
$("wiki-cmap-link").focus();
}
function insertSelectedWikiCmapLink() {
const slug = wikiCmapLinkCombobox.value();
if (slug === null || !slug) {
const input = $("wiki-cmap-link");
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
input.reportValidity();
return false;
}
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
if (!conceptMap) return false;
const label = escapeMarkdownLinkLabel(pendingWikiCmapLinkLabel.trim() || conceptMap.title);
insertTextAtCursor(`[${label}](cmap:${slug})`);
$("wiki-cmap-link-dialog").close();
return true;
}
function insertSelectedWikiCmapEmbed() {
const slug = wikiCmapLinkCombobox.value();
if (slug === null || !slug) {
const input = $("wiki-cmap-link");
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
input.reportValidity();
return false;
}
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
if (!conceptMap) return false;
insertTextAtCursor(`\n\n{{cmap:${conceptMap.slug}}}\n\n`);
$("wiki-cmap-link-dialog").close();
return true;
}
function isInlineImage(file) {
return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.type);
}
function requireUploadablePage() {
if (state.editingNew || !state.currentPage) {
throw new Error("Save a new page once before uploading files.");
}
return state.currentPage.slug;
}
async function uploadOneFile(file) {
const slug = requireUploadablePage();
$("save-status").textContent = `${tr("uploading", "Uploading")} ${file.name}`;
return api(`/api/pages/${encodeURIComponent(slug)}/upload`, {
method: "POST",
headers: { "X-File-Name": file.name },
body: file
});
}
async function uploadImageForEasyMDE(file) {
if (!isInlineImage(file)) {
throw new Error("Only PNG, JPEG, GIF and WebP can be inserted as images.");
}
const result = await uploadOneFile(file);
$("save-status").textContent = tr("image-upload-complete", "Image upload complete");
return result.url;
}
/**
* goal : Upload dropped/selected files and insert Markdown references at the cursor.
* pre : The current page has already been saved once.
* post : Uploaded files are stored through the API and referenced from the editor.
*/
async function uploadFiles(files) {
const fileList = Array.from(files || []);
if (fileList.length === 0) return;
try {
requireUploadablePage();
for (const file of fileList) {
const result = await uploadOneFile(file);
const escapedName = file.name.replace(/[\[\]]/g, "\\$&");
const markdown = isInlineImage(file)
? `![${escapedName}](${result.url})`
: `[${escapedName}](${result.url})`;
insertTextAtCursor(`\n${markdown}\n`);
}
$("save-status").textContent = tr("upload-complete", "Upload complete");
} catch (error) {
$("save-status").textContent = error.message;
throw error;
}
}
function containsNonImageFile(dataTransfer) {
const items = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file");
return items.some((item) => !item.type.startsWith("image/"));
}
function installGeneralFileDrop() {
const container = editorContainer();
if (!container) return;
container.addEventListener("dragenter", (event) => {
if (Array.from(event.dataTransfer?.types || []).includes("Files")) {
container.classList.add("dragging");
}
}, true);
container.addEventListener("dragover", (event) => {
if (containsNonImageFile(event.dataTransfer)) {
event.preventDefault();
}
}, true);
container.addEventListener("dragleave", (event) => {
if (!container.contains(event.relatedTarget)) {
container.classList.remove("dragging");
}
}, true);
container.addEventListener("drop", (event) => {
container.classList.remove("dragging");
const files = Array.from(event.dataTransfer?.files || []);
if (files.some((file) => !isInlineImage(file))) {
event.preventDefault();
event.stopImmediatePropagation();
uploadFiles(files).catch(() => {});
}
}, true);
}
/**
* goal : Open the rename/move form for the current page.
* pre : A current page exists and the user can edit pages.
* post : Rename fields contain current title, namespace and slug.
*/
function beginRenamePage() {
if (!state.currentPage || !can("editor")) return;
const address = splitPageReference(state.currentPage.slug);
$("rename-title").value = state.currentPage.title;
$("rename-namespace").value = state.currentPage.namespace || address.namespace;
$("rename-slug").value = state.currentPage.pageSlug || address.slug;
$("rename-status").textContent = "";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: state.currentPage.title, href: pageRoute(state.currentPage.slug) },
{ label: tr("rename", "Rename") }
]);
show("rename-view");
}
/**
* goal : Rename/move the current page while retaining the old address as an alias.
* pre : Rename fields contain a title, namespace and slug accepted by the server.
* post : Page metadata and aliases are refreshed and the canonical page is opened.
*/
async function saveRenamePage() {
if (!state.currentPage) return;
const oldReference = state.currentPage.slug;
$("rename-status").textContent = tr("saving", "Saving…");
try {
const page = await api(`/api/pages/${encodeURIComponent(oldReference)}/rename`, {
method: "POST",
body: JSON.stringify({
title: $("rename-title").value.trim(),
namespace: $("rename-namespace").value.trim(),
slug: $("rename-slug").value.trim(),
summary: tr("rename-summary", "Renamed page")
})
});
await loadPages();
await loadBookmarks();
state.currentPage = page;
const targetHash = pageRoute(page.slug);
if (location.hash === targetHash) {
await openPage(page.slug);
} else {
location.hash = targetHash;
}
} catch (error) {
$("rename-status").textContent = error.message;
}
}
async function deleteCurrentPage() {
if (!state.currentPage) return;
if (!confirm(`Archive page “${state.currentPage.title}”?`)) return;
await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}`, { method: "DELETE" });
state.currentPage = null;
await loadPages();
if (state.pages.length > 0) {
const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else {
$("page-title").textContent = tr("no-pages", "No pages yet");
$("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {});
show("page-view");
}
}
function buildUnifiedDiff(oldText, newText, oldName, newName) {
const oldLines = oldText.replace(/\r\n/g, "\n").split("\n");
const newLines = newText.replace(/\r\n/g, "\n").split("\n");
const n = oldLines.length;
const m = newLines.length;
if (n * m > 4_000_000) {
return [
`--- ${oldName}`,
`+++ ${newName}`,
`@@ -1,${n} +1,${m} @@`,
...oldLines.map((line) => `-${line}`),
...newLines.map((line) => `+${line}`)
].join("\n");
}
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
table[i][j] = oldLines[i] === newLines[j]
? table[i + 1][j + 1] + 1
: Math.max(table[i + 1][j], table[i][j + 1]);
}
}
const body = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
body.push(` ${oldLines[i]}`);
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
body.push(`-${oldLines[i++]}`);
} else {
body.push(`+${newLines[j++]}`);
}
}
while (i < n) body.push(`-${oldLines[i++]}`);
while (j < m) body.push(`+${newLines[j++]}`);
return [
`--- ${oldName}`,
`+++ ${newName}`,
`@@ -1,${n} +1,${m} @@`,
...body
].join("\n");
}
function appendSearchSnippet(target, snippet) {
const parts = String(snippet || "").split(/(\[\[\[|\]\]\])/);
let highlighted = false;
let mark = null;
for (const part of parts) {
if (part === "[[[") {
highlighted = true;
mark = document.createElement("mark");
target.append(mark);
} else if (part === "]]]") {
highlighted = false;
mark = null;
} else if (part) {
const text = document.createTextNode(part.replace(/\s+/g, " "));
if (highlighted && mark) mark.append(text);
else target.append(text);
}
}
}
async function searchWiki(query) {
const text = (query || "").trim();
if (!text) {
await route();
return;
}
const result = await api(`/api/search?q=${encodeURIComponent(text)}`);
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("search", "Search") }
]);
renderToc([], () => {});
show("search-view");
const results = $("search-results");
results.replaceChildren();
const resultWord = result.results.length === 1 ? tr("result", "result") : tr("results", "results");
$("search-summary").textContent = `${result.results.length} ${resultWord} ${tr("for", "for")}${text}`;
for (const item of result.results) {
const article = document.createElement("article");
article.className = "search-result";
article.dataset.resultType = item.type || "page";
const heading = document.createElement("h2");
const type = document.createElement("span");
type.className = "search-result-type";
type.textContent = item.type === "cmap" ?
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
const link = document.createElement("a");
link.href = item.type === "cmap" ?
cmapRoute(item.slug) : pageRoute(item.slug);
link.textContent = item.title;
heading.append(type, link);
const snippet = document.createElement("p");
appendSearchSnippet(snippet, item.snippet);
article.append(heading, snippet);
results.append(article);
}
if (result.results.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-matching-search-results", "No matching pages or concept maps.");
results.append(empty);
}
}
/**
* goal : Show immutable versions of the current page and diff actions.
* pre : state.currentPage identifies an existing page.
* post : History view contains newest-first versions.
*/
async function showHistory() {
if (!state.currentPage) return;
state.previousView = "page-view";
pageBreadcrumbs(state.currentPage, "History");
show("history-view");
const result = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/history`);
const list = $("history-list");
list.replaceChildren();
$("diff-target").replaceChildren();
result.versions.forEach((version, index) => {
const row = document.createElement("div");
row.className = "history-row";
const label = document.createElement("div");
label.innerHTML = `<strong>${pageDisplayDate(version.createdAt)}</strong><br><span class="muted"></span>`;
label.querySelector("span").textContent = `${version.author}${version.summary}`;
const view = document.createElement("button");
view.textContent = tr("view", "View");
view.addEventListener("click", async () => {
const full = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`);
$("diff-target").innerHTML = `<article class="markdown-body">${renderMarkdown(full.markdown, state.currentPage?.slug)}</article>`;
});
const compare = document.createElement("button");
compare.textContent = index + 1 < result.versions.length ? tr("compare-previous", "Compare previous") : "";
compare.disabled = index + 1 >= result.versions.length;
compare.addEventListener("click", async () => {
const older = result.versions[index + 1];
const [oldVersion, newVersion] = await Promise.all([
api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(older.version)}`),
api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`)
]);
const diff = buildUnifiedDiff(oldVersion.markdown, newVersion.markdown, older.version, version.version);
const target = $("diff-target");
target.replaceChildren();
const ui = new Diff2HtmlUI(target, diff, {
drawFileList: false,
matching: "lines",
outputFormat: "side-by-side",
highlight: false
});
ui.draw();
});
row.append(label, view, compare);
list.append(row);
});
}
async function showAdmin() {
state.previousView = state.currentPage ? "page-view" : "admin-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") }
]);
show("admin-view");
const info = await api("/api/admin/info");
$("admin-software-version").textContent = info.softwareVersion || "?";
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* goal : Show retained page aliases and pages still containing an old address.
* pre : Current user has the admin role.
* post : Alias administration contains canonical targets and reference links.
*/
async function loadAliasesAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("page-aliases", "Page aliases") }
]);
show("alias-admin-view");
$("alias-admin-status").textContent = "";
const result = await api("/api/admin/aliases");
const list = $("alias-list");
list.replaceChildren();
const aliases = result.aliases || [];
if (aliases.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-page-aliases", "No page aliases.");
list.append(empty);
return;
}
for (const alias of aliases) {
const row = document.createElement("article");
row.className = "alias-row";
const address = document.createElement("div");
address.className = "alias-address";
const target = document.createElement("a");
target.href = pageRoute(alias.targetSlug);
target.textContent = alias.targetSlug;
address.append(
document.createTextNode(`${tr("old-address", "Old address")}: ${alias.slug} · ${tr("current-address", "Current address")}: `),
target
);
row.append(address);
const refs = alias.references || [];
const currentSummary = document.createElement("div");
currentSummary.className = "alias-summary";
currentSummary.textContent = `${tr("current-references", "Current references")}: ${refs.length}`;
row.append(currentSummary);
if (refs.length > 0) {
const refsList = document.createElement("ul");
refsList.className = "alias-references";
for (const ref of refs) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = pageRoute(ref.slug);
link.textContent = ref.title;
item.append(link);
refsList.append(item);
}
row.append(refsList);
}
const historicalCount = Number(alias.historicalReferenceCount || 0);
const historicalSummary = document.createElement("div");
historicalSummary.className = "alias-summary muted";
historicalSummary.textContent = `${tr("historical-references", "Historical references")}: ${historicalCount}`;
row.append(historicalSummary);
const historicalRefs = alias.historicalReferences || [];
if (historicalRefs.length > 0) {
const historyList = document.createElement("ul");
historyList.className = "alias-references alias-history";
for (const ref of historicalRefs) {
const item = document.createElement("li");
item.textContent = `${ref.title} · ${tr("version", "Version")} ${ref.version}`;
historyList.append(item);
}
row.append(historyList);
}
const actions = document.createElement("div");
actions.className = "alias-actions";
const cleanup = document.createElement("button");
cleanup.type = "button";
cleanup.textContent = tr("cleanup-alias", "Clean up references");
cleanup.disabled = refs.length === 0;
cleanup.addEventListener("click", async () => {
if (!window.confirm(tr("cleanup-alias-confirm", "Replace all current references to this old address with the current address? Historical versions will not be changed."))) return;
const cleanupResult = await api(`/api/admin/aliases/${alias.id}/cleanup`, { method: "POST" });
await loadPages();
await loadAliasesAdmin();
$("alias-admin-status").textContent =
`${tr("cleanup-complete", "Cleanup complete")}: ${cleanupResult.changedPages || 0}`;
});
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = tr("delete-alias", "Delete alias");
remove.disabled = refs.length > 0;
remove.addEventListener("click", async () => {
const warning = historicalCount > 0
? tr("delete-alias-history-confirm", "Delete this alias? Historical page versions still contain this old address and those links may stop resolving.")
: tr("delete-alias-confirm", "Delete this alias?");
if (!window.confirm(warning)) return;
await api(`/api/admin/aliases/${alias.id}`, { method: "DELETE" });
await loadPages();
await loadAliasesAdmin();
});
actions.append(cleanup, remove);
row.append(actions);
list.append(row);
}
}
/**
* goal : Show uploads no current page references and their last historical uses.
* pre : Current user has the admin role.
* post : Admin view offers safe deletion only for orphaned uploads.
*/
async function loadOrphanedUploadsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("orphaned-uploads", "Orphaned uploads") }
]);
show("orphaned-uploads-view");
const result = await api("/api/admin/uploads/orphaned");
const list = $("orphaned-uploads-list");
list.replaceChildren();
if (!result.uploads || result.uploads.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-orphaned-uploads", "No orphaned uploads.");
list.append(empty);
return;
}
for (const upload of result.uploads) {
const row = document.createElement("article");
row.className = "orphaned-upload-row";
const title = document.createElement("strong");
title.textContent = upload.originalName;
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ${tr("uploaded-by", "uploaded by")} ${upload.uploadedBy}`;
const history = document.createElement("div");
const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : [];
if (lastUses.length > 0) {
const label = document.createElement("div");
label.textContent = `${tr("last-used", "Last used")}:`;
history.append(label);
const uses = document.createElement("ul");
uses.className = "orphaned-upload-uses";
for (const use of lastUses) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(use.slug)}`;
link.textContent = use.title || use.slug;
item.append(link);
if (use.version) item.append(document.createTextNode(` · ${tr("version", "Version")} ${use.version}`));
uses.append(item);
}
history.append(uses);
} else {
history.textContent = tr("never-referenced", "Never referenced by a saved page.");
}
const actions = document.createElement("div");
actions.className = "orphaned-upload-actions";
const view = document.createElement("a");
view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/${encodeURIComponent(upload.storedName)}`;
view.target = "_blank";
view.rel = "noopener";
view.textContent = tr("view", "View");
const remove = document.createElement("button");
remove.className = "danger";
remove.textContent = tr("delete", "Delete");
remove.addEventListener("click", async () => {
if (!confirm(tr("delete-orphaned-upload-confirm", "Delete this orphaned upload?"))) return;
await api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" });
await loadOrphanedUploadsAdmin();
});
actions.append(view, remove);
row.append(title, meta, history, actions);
list.append(row);
}
}
/**
* goal : Render user administration from the current server-side user list.
* pre : Current user has the admin role.
* post : User rows allow supported create/update/delete operations.
*/
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("users", "Users") }
]);
show("user-admin-view");
const result = await api("/api/admin/users");
const list = $("user-list");
list.replaceChildren();
for (const user of result.users) {
const row = document.createElement("div");
row.className = "user-row";
const username = document.createElement("strong");
username.textContent = user.username;
const display = document.createElement("input");
display.value = user.displayName;
const email = document.createElement("input");
email.type = "email";
email.value = user.email || "";
email.placeholder = tr("email-address", "Email address");
const role = document.createElement("select");
for (const roleName of ["reader", "editor", "admin"]) {
const option = document.createElement("option");
option.value = roleName;
option.textContent = tr(`role-${roleName}`, roleName);
option.selected = roleName === user.role;
role.append(option);
}
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
const password = document.createElement("input");
password.type = "password";
password.placeholder = tr("new-password", "New password");
password.autocomplete = "new-password";
const actions = document.createElement("div");
const save = document.createElement("button");
save.textContent = tr("save", "Save");
save.addEventListener("click", async () => {
await api(`/api/admin/users/${user.id}`, {
method: "PUT",
body: JSON.stringify({
displayName: display.value,
email: email.value,
role: role.value,
enabled: enabled.checked,
password: password.value || undefined
})
});
password.value = "";
});
const remove = document.createElement("button");
remove.textContent = tr("delete", "Delete");
remove.className = "danger";
remove.addEventListener("click", async () => {
if (!confirm(`Delete user ${user.username}?`)) return;
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
await loadUsersAdmin();
});
actions.append(save, remove);
row.append(username, display, email, role, enabled, password, actions);
list.append(row);
}
}
function showProfile() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("profile", "Profile") }
]);
show("profile-view");
const user = state.session.user;
$("profile-username").value = user.username;
$("profile-display-name").value = user.displayName;
$("profile-email").value = user.email || "";
$("profile-current-password").value = "";
$("profile-new-password").value = "";
$("profile-repeat-password").value = "";
$("profile-status").textContent = "";
}
async function loadMailSettingsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin"), href: "#admin" },
{ label: tr("email-and-password-reset", "Email and password reset") }
]);
show("mail-admin-view");
const settings = await api("/api/admin/mail-settings");
$("mail-public-url").value = settings.publicUrl || window.location.origin;
$("mail-smtp-host").value = settings.smtpHost || "";
$("mail-smtp-port").value = settings.smtpPort || "587";
$("mail-smtp-from").value = settings.smtpFrom || "";
$("mail-smtp-user").value = settings.smtpUser || "";
$("mail-smtp-password").value = "";
$("mail-smtp-tls").checked = settings.smtpTls !== false;
$("mail-smtp-accept-untrusted-certificates").checked = settings.smtpAcceptUntrustedCertificates === true;
$("mail-smtp-accept-untrusted-certificates").disabled = !$("mail-smtp-tls").checked;
$("mail-reset-limit").value = settings.resetLimit || "2";
$("mail-test-recipient").value = state.session.user.email || "";
$("mail-password-help").textContent = settings.hasPassword ? tr("smtp-password-kept", "A password is stored; leave empty to keep it.") : "";
$("mail-settings-status").textContent = "";
}
function mailSettingsFormData() {
return {
publicUrl: $("mail-public-url").value,
smtpHost: $("mail-smtp-host").value,
smtpPort: $("mail-smtp-port").value,
smtpFrom: $("mail-smtp-from").value,
smtpUser: $("mail-smtp-user").value,
smtpPassword: $("mail-smtp-password").value,
smtpTls: $("mail-smtp-tls").checked,
smtpAcceptUntrustedCertificates: $("mail-smtp-accept-untrusted-certificates").checked,
resetLimit: $("mail-reset-limit").value
};
}
function smtpErrorMessage(error) {
const message = error && error.message ? error.message : String(error);
if (message.includes("certificate verify failed") || message.includes("TLS certificate verification failed")) {
return tr("smtp-certificate-verification-failed", "The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server.");
}
if (message.includes("no protocols available")) {
return tr("smtp-no-modern-tls", "The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically.");
}
return message;
}
function showNotFound(slug) {
state.currentPage = null;
state.editingNew = false;
state.newPageSlug = null;
$("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`;
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: slug }
]);
show("not-found-view");
renderToc([], () => {});
renderPageList();
}
function showMissingEditablePage(slug) {
state.currentPage = null;
state.editingNew = false;
state.newPageSlug = slug;
$("page-title").textContent = slugTitle(slug) || slug;
$("page-meta").textContent = tr("this-page-missing", "This page does not exist yet.");
$("markdown-preview").innerHTML = "";
$("page-details").replaceChildren();
$("page-cmap-connections").classList.add("hidden");
$("page-cmap-connections-list").replaceChildren();
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: slugTitle(slug) || slug }
]);
show("page-view");
setPageActionVisibility(false);
renderToc([], () => {});
renderPageList();
}
/**
* goal : Resolve the current hash route to a normal or special wiki view.
* pre : Session and page metadata have been loaded.
* post : Exactly one application view is made active.
*/
async function route() {
if (location.hash === "#profile") {
showProfile();
return;
}
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
if (todoMatch) {
await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2]));
return;
}
const cmapMatch = location.hash.match(/^#cmap\/([^/]+)$/);
if (cmapMatch) {
await showCmapPrototype(decodeURIComponent(cmapMatch[1]));
return;
}
if (location.hash === "#cmaps") {
await showCmapPrototype();
return;
}
if (location.hash === "#graph") {
await showGraph();
return;
}
if (location.hash === "#recent") {
await showRecent();
return;
}
if (location.hash === "#bookmarks") {
await showBookmarks();
return;
}
if (location.hash === "#todos") {
await showTodos();
return;
}
const searchMatch = location.hash.match(/^#search\/(.+)$/);
if (searchMatch) {
const query = decodeURIComponent(searchMatch[1]);
$("search-input").value = query;
await searchWiki(query);
return;
}
if (location.hash === "#admin" && can("admin")) {
await showAdmin();
return;
}
if (location.hash === "#admin/users" && can("admin")) {
await loadUsersAdmin();
return;
}
if (location.hash === "#admin/mail" && can("admin")) {
await loadMailSettingsAdmin();
return;
}
if (location.hash === "#admin/aliases" && can("admin")) {
await loadAliasesAdmin();
return;
}
if (location.hash === "#admin/orphaned-uploads" && can("admin")) {
await loadOrphanedUploadsAdmin();
return;
}
const match = location.hash.match(/^#\/([^/]+)$/);
if (match) {
const slug = decodeURIComponent(match[1]);
try {
await openPage(slug);
} catch (error) {
if (error.status !== 404) {
throw error;
}
if (can("editor")) {
showMissingEditablePage(slug);
} else {
showNotFound(slug);
}
}
return;
}
if (state.pages.length > 0) {
const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else {
if (can("editor")) {
showMissingEditablePage("start");
} else {
state.currentPage = null;
$("page-title").textContent = tr("no-pages", "No pages yet");
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {});
show("page-view");
}
}
}
function formatTimestamp(value) {
const date = new Date(Number(value) * 1000);
return date.toLocaleString(state.language || undefined);
}
////////////////////////////////////////////////////////////////////////////////
// Special wiki views: Recent, Bookmarks and Todo
////////////////////////////////////////////////////////////////////////////////
async function showRecent() {
state.previousView = state.currentPage ? "page-view" : "recent-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("recent", "Recent") }
]);
setPageActionVisibility(false);
show("recent-view");
const result = await api("/api/recent");
const list = $("recent-list");
list.replaceChildren();
const items = result.items || [];
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-recent-changes", "No recent changes.");
list.append(empty);
return;
}
for (const item of items) {
const row = document.createElement("div");
row.className = "special-page-row";
row.dataset.resultType = item.type;
const heading = document.createElement("span");
heading.className = "special-page-heading";
const type = document.createElement("span");
type.className = "search-result-type";
type.textContent = item.type === "cmap" ?
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
const link = document.createElement("a");
link.href = item.type === "cmap" ? cmapRoute(item.slug) : pageRoute(item.slug);
link.textContent = item.title;
const meta = document.createElement("span");
meta.className = "special-page-meta";
meta.textContent = `${formatTimestamp(item.updatedAt)} · ${tr("changed-by", "changed by")} ${item.updatedBy}`;
heading.append(type, link);
row.append(heading, meta);
list.append(row);
}
}
async function saveBookmark(slug, existingSection = "") {
const section = window.prompt(tr("bookmark-section", "Bookmark section"), existingSection);
if (section === null) return;
await api("/api/bookmarks", {
method: "POST",
body: JSON.stringify({ slug, section: section.trim() })
});
await loadBookmarks();
}
async function removeBookmark(slug) {
await api(`/api/bookmarks/${encodeURIComponent(slug)}`, { method: "DELETE" });
await loadBookmarks();
}
/**
* goal : Show bookmarks grouped first by page namespace and then user section.
* pre : The user is authenticated.
* post : The Bookmarks special view contains the latest bookmark state.
*/
async function showBookmarks() {
state.previousView = state.currentPage ? "page-view" : "bookmarks-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("bookmarks", "Bookmarks") }
]);
setPageActionVisibility(false);
show("bookmarks-view");
await loadBookmarks();
const target = $("bookmarks-list");
target.replaceChildren();
if (state.bookmarks.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-bookmarks", "No bookmarks yet.");
target.append(empty);
return;
}
const namespaceGroups = new Map();
for (const bookmark of state.bookmarks) {
const namespace = bookmark.namespace || "";
if (!namespaceGroups.has(namespace)) namespaceGroups.set(namespace, new Map());
const sections = namespaceGroups.get(namespace);
const section = bookmark.section || "";
if (!sections.has(section)) sections.set(section, []);
sections.get(section).push(bookmark);
}
for (const [namespace, sections] of namespaceGroups.entries()) {
const namespaceSection = document.createElement("section");
const namespaceHeading = document.createElement("h2");
namespaceHeading.textContent = namespaceLabel(namespace);
namespaceSection.append(namespaceHeading);
for (const [section, bookmarks] of sections.entries()) {
const sectionElement = document.createElement("section");
sectionElement.className = "bookmark-section";
if (section) {
const heading = document.createElement("h3");
heading.textContent = section;
sectionElement.append(heading);
}
for (const bookmark of bookmarks) {
const row = document.createElement("div");
row.className = "bookmark-row";
const link = document.createElement("a");
link.href = pageRoute(bookmark.slug);
link.textContent = bookmark.title;
const actions = document.createElement("span");
actions.className = "bookmark-section-actions";
const move = document.createElement("a");
move.href = "#";
move.textContent = tr("move", "Move");
move.addEventListener("click", async (event) => {
event.preventDefault();
await saveBookmark(bookmark.slug, bookmark.section || "");
await showBookmarks();
});
const separator = document.createTextNode(" · ");
const remove = document.createElement("a");
remove.href = "#";
remove.textContent = tr("remove", "Remove");
remove.addEventListener("click", async (event) => {
event.preventDefault();
await removeBookmark(bookmark.slug);
await showBookmarks();
});
actions.append(move, separator, remove);
row.append(link, actions);
sectionElement.append(row);
}
namespaceSection.append(sectionElement);
}
target.append(namespaceSection);
}
}
/**
* goal : Show Todo items grouped by the namespace of their source page.
* pre : Todo index is available through /api/todos.
* post : Optional target Todo is scrolled into view and highlighted.
*/
async function showTodos(targetSlug = null, targetNumber = null) {
state.previousView = state.currentPage ? "page-view" : "todo-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("todo", "Todo") }
]);
renderToc([], () => {});
show("todo-view");
const result = await api("/api/todos");
const list = $("todo-list");
list.replaceChildren();
if (result.items.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-todos", "No todo items.");
list.append(empty);
return;
}
let todoNamespace = null;
for (const item of result.items) {
const namespace = item.namespace || "";
if (namespace !== todoNamespace) {
const heading = document.createElement("h2");
heading.className = "namespace-heading";
heading.textContent = namespaceLabel(namespace);
list.append(heading);
todoNamespace = namespace;
}
const row = document.createElement("article");
row.className = "todo-item";
row.id = `todo-${item.slug}-${item.number}`;
const link = document.createElement("a");
link.href = pageRoute(item.slug);
link.textContent = item.title;
const text = document.createElement("div");
text.className = "todo-item-text";
text.textContent = item.text;
const location = document.createElement("span");
location.className = "muted";
location.textContent = `${tr("line", "line")} ${item.line}`;
row.append(link, text, location);
list.append(row);
}
if (targetSlug && targetNumber) {
const target = document.getElementById(`todo-${targetSlug}-${targetNumber}`);
if (target) {
target.classList.add("todo-item-target");
target.scrollIntoView({ block: "center" });
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Concept map prototype
////////////////////////////////////////////////////////////////////////////////
/**
* goal : Return the in-memory concept-map prototype state.
* post : A state object exists for the current CMap prototype.
* result : Object containing the RacketWikiCmap editor instance.
*/
function cmapPrototypeState() {
if (!state.cmapPrototype) {
state.cmapPrototype = {
editor: null
};
}
return state.cmapPrototype;
}
/**
* goal : Render one concept card inside an ionstage/cmap node.
* pre : record contains label, synopsis and kind.
* result : Safe HTML used as the node's content.
*/
function cmapNodeHtml(record) {
const safeLabel = escapeHtml(record.label || "");
const safeSynopsis = escapeHtml(record.synopsis || "");
const safeKind = escapeHtml(record.kind || "concept");
const safeImageSource = escapeHtml(record.imageSource || "");
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
.join("");
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
const usageCount = record.conceptId && record.kind !== "phrase" && record.kind !== "submap" ?
effectiveCmapConceptUsage(record) : null;
const usageLabel = usageCount === null ? "" :
tr("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(usageCount));
const usage = usageCount === null ? "" :
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(tr("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
}
function effectiveCmapConceptUsage(record) {
const byMap = state.cmapConceptUsage.get(record.conceptId);
const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0;
const prototype = cmapPrototypeState();
const isActiveEditorRecord = Boolean(prototype.editor && prototype.editor.items.includes(record));
if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1);
const currentSlug = state.currentConceptMap ? state.currentConceptMap.slug : null;
const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0;
return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1));
}
function ensureCmapDescriptionTooltip() {
if (cmapDescriptionTooltip) return cmapDescriptionTooltip;
cmapDescriptionTooltip = document.createElement("div");
cmapDescriptionTooltip.id = "cmap-description-tooltip";
cmapDescriptionTooltip.className = "cmap-description-tooltip hidden";
cmapDescriptionTooltip.setAttribute("role", "tooltip");
document.body.append(cmapDescriptionTooltip);
return cmapDescriptionTooltip;
}
function hideCmapDescriptionTooltip() {
cmapDescriptionTooltipToken += 1;
if (cmapDescriptionTooltip) cmapDescriptionTooltip.classList.add("hidden");
}
async function showCmapDescriptionTooltip(button) {
if (!button.classList.contains("is-filled")) return;
const itemElement = button.closest("[data-rw-cmap-item-id]");
const prototype = cmapPrototypeState();
const record = itemElement && prototype.editor ? prototype.editor.items.find((item) =>
item.id === Number(itemElement.dataset.rwCmapItemId)) : null;
if (!record || !record.descriptionPageSlug) return;
const reference = canonicalPageReference(record.descriptionPageSlug);
const tooltip = ensureCmapDescriptionTooltip();
const token = ++cmapDescriptionTooltipToken;
button.setAttribute("aria-describedby", tooltip.id);
tooltip.classList.remove("hidden");
tooltip.textContent = tr("loading", "Loading…");
const rect = button.getBoundingClientRect();
tooltip.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 440))}px`;
tooltip.style.top = `${Math.min(window.innerHeight - 180, rect.bottom + 8)}px`;
try {
let preview = cmapDescriptionPreviewCache.get(reference);
if (preview === undefined) {
const page = await api(`/api/pages/${encodeURIComponent(reference)}`);
preview = String(page.markdown || "").trim() ? renderMarkdown(page.markdown, page.slug) : null;
cmapDescriptionPreviewCache.set(reference, preview);
}
if (token !== cmapDescriptionTooltipToken) return;
if (!preview) {
button.classList.remove("is-filled");
button.classList.add("is-empty");
tooltip.classList.add("hidden");
return;
}
tooltip.innerHTML = `<article class="markdown-body">${preview}</article>`;
const height = tooltip.offsetHeight;
if (rect.bottom + 8 + height > window.innerHeight) {
tooltip.style.top = `${Math.max(8, rect.top - height - 8)}px`;
}
} catch (error) {
if (token !== cmapDescriptionTooltipToken) return;
button.classList.remove("is-filled");
button.classList.add("is-empty");
tooltip.classList.add("hidden");
if (error.status !== 404) console.error(error);
}
}
/**
* goal : Derive a compact synopsis from the currently opened wiki page.
* pre : state.currentPage may be #f/null when no page is open.
* result : Plain text of at most about 150 characters.
*/
function currentPageSynopsis() {
if (!state.currentPage) return "";
const container = document.createElement("div");
container.innerHTML = renderMarkdown(state.currentPage.content || "");
const text = (container.textContent || "").replace(/\s+/g, " ").trim();
return text.length > 150 ? `${text.slice(0, 147)}` : text;
}
/**
* goal : Add a concept-like item to the active CMap prototype.
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
* post : The item is draggable, selectable, resizable and linkable.
* result : The item record created by the interaction layer.
*/
function addCmapPrototypeNode(options = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
return prototype.editor.addItem(options);
}
let cmapDialogRecord = null;
let cmapDialogCreateContext = null;
let cmapContextCreateContext = {};
let cmapDialogImageSource = "";
let cmapDialogImageRead = Promise.resolve();
function cmapColorValue(value, fallback = "#f3f6f8") {
return /^#[0-9a-f]{6}$/i.test(value || "") ? value : fallback;
}
function cmapFontSizeInPoints(value, baseSize = 11) {
const size = Number.parseFloat(value);
if (!Number.isFinite(size)) return 11;
if (/px$/i.test(value || "")) return size * 0.75;
if (/em$/i.test(value || "")) return size * baseSize;
if (/%$/i.test(value || "")) return (size / 100) * baseSize;
return size;
}
function displayCmapFontSize(value) {
return String(Math.round(value * 100) / 100);
}
function selectCmapFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = $(selectId);
const value = fontFamily || "Arial, Helvetica, sans-serif";
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
function titledCmapComboboxEntry(record) {
const label = record.title || record.slug;
return {
value: record.slug,
label,
description: record.slug
};
}
function populateCmapPageOptions(record) {
const pages = [...state.pages].sort((a, b) => a.title.localeCompare(b.title));
const entries = pages.map((page) => titledCmapComboboxEntry(page));
if (record.pageSlug && !pages.some((page) => page.slug === record.pageSlug)) {
entries.push({ value: record.pageSlug, label: record.pageSlug });
}
cmapPageCombobox.setOptions(entries, record.pageSlug || "");
$("cmap-concept-page-row").classList.toggle("hidden", record.kind === "submap");
}
function populateCmapLinkOptions(record) {
const prototype = cmapPrototypeState();
const entries = [];
if (prototype.editor && prototype.editor.activeMapRoot) {
const parentLabel = `${tr("parent-concept-map", "Parent concept map")}`;
entries.push({ value: "__parent__", label: parentLabel });
}
for (const conceptMap of [...state.conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) {
entries.push(titledCmapComboboxEntry(conceptMap));
}
if (record.cmapSlug && !state.conceptMaps.some((conceptMap) => conceptMap.slug === record.cmapSlug)) {
entries.push({ value: record.cmapSlug, label: record.cmapSlug });
}
const selectedValue = record.parentCmapLink ? "__parent__" : (record.cmapSlug || "");
cmapLinkCombobox.setOptions(entries, selectedValue);
$("cmap-concept-cmap-row").classList.toggle("hidden", record.kind === "submap");
}
function updateCmapImagePreview() {
const row = $("cmap-concept-image-preview-row");
const preview = $("cmap-concept-image-preview");
if (!cmapDialogImageSource) {
row.classList.add("hidden");
preview.removeAttribute("src");
return;
}
preview.src = cmapDialogImageSource;
row.classList.remove("hidden");
}
function readCmapImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true });
reader.addEventListener("error", () => reject(reader.error || new Error("Image could not be read.")), { once: true });
reader.readAsDataURL(file);
});
}
/**
* goal : Open the concept editor without changing its wiki-page identity.
* pre : record is a non-phrase item in the current CMap editor.
* post : A modal form shows concept text, presentation and image fields.
*/
function openCmapConceptDialog(record) {
if (!record || record.kind === "phrase") return;
cmapDialogRecord = record;
cmapDialogCreateContext = null;
$("cmap-concept-dialog-title").textContent = tr("edit-concept", "Edit concept");
cmapDialogImageSource = record.imageSource || "";
cmapDialogImageRead = Promise.resolve();
$("cmap-concept-label").value = record.label || "";
$("cmap-concept-synopsis").value = record.synopsis || "";
$("cmap-concept-aspects").value = (record.aspects || []).join(", ");
$("cmap-concept-description-page").value = record.descriptionPageSlug || "";
$("cmap-concept-description-link").href = record.descriptionPageSlug ?
pageRoute(record.descriptionPageSlug) : "#";
$("cmap-concept-description-link").classList.toggle("hidden", !record.descriptionPageSlug);
$("cmap-concept-background").value = cmapColorValue(record.backgroundColor);
$("cmap-concept-background-label").textContent = record.kind === "submap" ?
tr("main-concept-background-color", "Main concept background color") :
tr("background-color", "Background color");
$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
$("cmap-submap-background").value = cmapColorValue(
record.submapBackgroundColor, "#edf7e8");
$("cmap-submap-border").value = cmapColorValue(
record.submapBorderColor, "#57834a");
$("cmap-concept-text-color").value = cmapColorValue(record.textColor, "#222222");
selectCmapFont(record.fontFamily);
const titleFontSize = cmapFontSizeInPoints(record.fontSize);
$("cmap-concept-font-size").value = displayCmapFontSize(titleFontSize);
$("cmap-concept-bold").checked = String(record.fontWeight || "700") !== "400";
$("cmap-concept-italic").checked = record.fontStyle === "italic";
$("cmap-concept-synopsis-text-color").value = cmapColorValue(
record.synopsisTextColor || record.textColor, "#222222");
selectCmapFont(
record.synopsisFontFamily || record.fontFamily,
"cmap-concept-synopsis-font-family");
$("cmap-concept-synopsis-font-size").value = displayCmapFontSize(
cmapFontSizeInPoints(record.synopsisFontSize || "0.84em", titleFontSize));
$("cmap-concept-synopsis-bold").checked = String(
record.synopsisFontWeight || record.fontWeight || "700") !== "400";
$("cmap-concept-synopsis-italic").checked =
(record.synopsisFontStyle || record.fontStyle) === "italic";
$("cmap-concept-style-preset").value = "default";
$("cmap-concept-image").value = "";
populateCmapPageOptions(record);
populateCmapLinkOptions(record);
updateCmapImagePreview();
for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) {
updateCmapColorControl(input);
}
$("cmap-concept-dialog").showModal();
$("cmap-concept-label").focus();
$("cmap-concept-label").select();
}
function openNewCmapConceptDialog(context = {}) {
cmapDialogRecord = null;
cmapDialogCreateContext = context;
cmapDialogImageSource = "";
cmapDialogImageRead = Promise.resolve();
$("cmap-concept-dialog-title").textContent = tr("add-concept", "Add concept");
$("cmap-concept-label").value = "New concept";
$("cmap-concept-synopsis").value = "";
$("cmap-concept-aspects").value = "";
$("cmap-concept-description-page").value = "";
$("cmap-concept-description-link").classList.add("hidden");
$("cmap-concept-background").value = "#fff4cf";
$("cmap-concept-background-label").textContent = tr("background-color", "Background color");
$("cmap-submap-style-fields").classList.add("hidden");
$("cmap-submap-background").value = "#edf7e8";
$("cmap-submap-border").value = "#57834a";
$("cmap-concept-text-color").value = "#222222";
selectCmapFont("Arial, Helvetica, sans-serif");
$("cmap-concept-font-size").value = "11";
$("cmap-concept-bold").checked = true;
$("cmap-concept-italic").checked = false;
$("cmap-concept-synopsis-text-color").value = "#4d4d4d";
selectCmapFont("Arial, Helvetica, sans-serif", "cmap-concept-synopsis-font-family");
$("cmap-concept-synopsis-font-size").value = "9";
$("cmap-concept-synopsis-bold").checked = false;
$("cmap-concept-synopsis-italic").checked = false;
$("cmap-concept-style-preset").value = "default";
$("cmap-concept-image").value = "";
populateCmapPageOptions({ kind: "concept", pageSlug: null });
populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false });
updateCmapImagePreview();
for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) {
updateCmapColorControl(input);
}
$("cmap-concept-dialog").showModal();
$("cmap-concept-label").focus();
$("cmap-concept-label").select();
}
const cmapStylePresets = {
default: { background: "#fff4cf", text: "#222222", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: false },
subtle: { background: "#f1f3f5", text: "#56616b", font: "system-ui, sans-serif", size: 10, bold: false, italic: false },
emphasis: { background: "#e7f2fb", text: "#173b57", font: "Georgia, Times New Roman, serif", size: 12, bold: true, italic: false },
warning: { background: "#fff0d5", text: "#713b00", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: true },
success: { background: "#e6f4e2", text: "#285b27", font: "Arial, Helvetica, sans-serif", size: 11, bold: false, italic: true }
};
const defaultCmapColorPalette = ["#ffffff", "#f1f3f5", "#e7f2fb", "#e6f4e2", "#fff4cf", "#fff0d5",
"#f7dede", "#dcd8f7", "#222222", "#4479a1", "#57834a", "#a97c00"];
const cmapColorPaletteStorageKey = "racket-wiki:cmap-color-palette:v1";
function loadCmapColorPalette() {
try {
const stored = JSON.parse(window.localStorage.getItem(cmapColorPaletteStorageKey) || "null");
if (Array.isArray(stored) && stored.length === defaultCmapColorPalette.length) {
return stored.map((color, index) =>
cmapColorValue(color, defaultCmapColorPalette[index]));
}
} catch (error) {
console.warn("The CMap color palette could not be read.", error);
}
return [...defaultCmapColorPalette];
}
function storeCmapColorPalette(colors) {
try {
window.localStorage.setItem(cmapColorPaletteStorageKey, JSON.stringify(colors));
} catch (error) {
console.warn("The CMap color palette could not be stored.", error);
}
}
function openNativeCmapColorPicker(initialColor, onInput) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => window.setTimeout(cleanup, 0), { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
function updateCmapColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
function installCmapColorPickers() {
const colors = loadCmapColorPalette();
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = tr("color-swatch-help",
"Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
colors.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
choice.style.backgroundColor = color;
choice.title = `${color}${tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = colors[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
openNativeCmapColorPicker(colors[index], (newColor) => {
colors[index] = newColor;
for (const matchingChoice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
matchingChoice.style.backgroundColor = newColor;
matchingChoice.title = `${newColor}${tr("change-palette-color", "double-click to change")}`;
matchingChoice.setAttribute("aria-label", newColor);
}
storeCmapColorPalette(colors);
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
openNativeCmapColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => updateCmapColorControl(input));
updateCmapColorControl(input);
}
}
function applySelectedCmapStyle() {
const preset = cmapStylePresets[$("cmap-concept-style-preset").value];
if (!preset) return;
$("cmap-concept-background").value = preset.background;
$("cmap-concept-text-color").value = preset.text;
selectCmapFont(preset.font);
$("cmap-concept-font-size").value = String(preset.size);
$("cmap-concept-bold").checked = preset.bold;
$("cmap-concept-italic").checked = preset.italic;
$("cmap-concept-synopsis-text-color").value = preset.text;
selectCmapFont(preset.font, "cmap-concept-synopsis-font-family");
$("cmap-concept-synopsis-font-size").value = String(Math.max(6, preset.size - 2));
$("cmap-concept-synopsis-bold").checked = false;
$("cmap-concept-synopsis-italic").checked = preset.italic;
updateCmapColorControl($("cmap-concept-background"));
updateCmapColorControl($("cmap-concept-text-color"));
updateCmapColorControl($("cmap-concept-synopsis-text-color"));
}
function setCmapZoom(value) {
const prototype = cmapPrototypeState();
const percent = Math.max(25, Math.min(300, Number(value) || 100));
$("cmap-zoom-percent").value = String(percent);
try {
window.localStorage.setItem(cmapZoomStorageKey(), String(percent));
} catch (error) {
console.warn("The CMap zoom factor could not be stored.", error);
}
const canvas = $("cmap-canvas");
canvas.style.setProperty("--cmap-a4-width", `${1123 * percent / 100}px`);
canvas.style.setProperty("--cmap-a4-height", `${794 * percent / 100}px`);
return prototype.editor ? prototype.editor.setZoom(percent) : percent;
}
function setCmapPageGuides(visible) {
$("cmap-canvas").classList.toggle("cmap-page-guides-hidden", !visible);
$("cmap-toggle-page-guides").setAttribute("aria-checked", String(visible));
try {
window.localStorage.setItem("racket-wiki:cmap-a4-page-guides", visible ? "true" : "false");
} catch (error) {
console.warn("The CMap page-boundary preference could not be stored.", error);
}
}
function restoreCmapPageGuides() {
let visible = true;
try {
visible = window.localStorage.getItem("racket-wiki:cmap-a4-page-guides") !== "false";
} catch (error) {
console.warn("The CMap page-boundary preference could not be read.", error);
}
setCmapPageGuides(visible);
}
function showCmapStatus(message, temporary = false) {
const status = $("cmap-save-status");
window.clearTimeout(cmapStatusTimer);
status.textContent = message || "";
status.classList.toggle("cmap-status-visible", Boolean(message));
if (message && temporary) {
cmapStatusTimer = window.setTimeout(() => {
status.classList.remove("cmap-status-visible");
status.textContent = "";
}, 1800);
}
}
function renderHiddenCmapItems() {
const details = $("cmap-hidden-items");
const options = $("cmap-hidden-item-options");
const prototype = cmapPrototypeState();
const hidden = prototype.editor ? prototype.editor.hiddenItemsInCurrentContext() : [];
details.classList.toggle("hidden", hidden.length === 0);
details.querySelector("summary").textContent = hidden.length ?
`${tr("hidden-concepts", "Hidden concepts")} (${hidden.length})` :
tr("hidden-concepts", "Hidden concepts");
options.replaceChildren();
for (const record of hidden) {
const button = document.createElement("button");
button.type = "button";
button.textContent = record.label;
button.title = tr("show-hidden-concept", "Show this concept again");
button.addEventListener("click", () => {
prototype.editor.showItemInCurrentContext(record);
details.open = false;
});
options.append(button);
}
}
function cmapZoomStorageKey() {
const storedMap = state.currentConceptMap ? state.currentConceptMap.slug : "unsaved";
const prototype = cmapPrototypeState();
const activeMap = prototype.editor && prototype.editor.activeMapRoot &&
prototype.editor.activeMapRoot.mapReference ? prototype.editor.activeMapRoot.mapReference.id : "root";
return `racket-wiki:cmap-zoom:${storedMap}:${activeMap}`;
}
function restoreCmapZoom() {
let stored = 100;
try {
stored = Number(window.localStorage.getItem(cmapZoomStorageKey()));
} catch (error) {
console.warn("The stored CMap zoom factor could not be read.", error);
}
return setCmapZoom(stored >= 25 && stored <= 300 ? stored : 100);
}
function closeCmapContextMenu() {
$("cmap-context-menu").classList.add("hidden");
$("cmap-tools-menu").setAttribute("aria-expanded", "false");
}
function openCmapContextMenu(clientX, clientY, createContext = {}) {
const menu = $("cmap-context-menu");
cmapContextCreateContext = createContext;
// The CMap library assigns ever-increasing z-indices to map components.
// Keep this fixed-position menu outside that stacking context.
if (menu.parentElement !== document.body) document.body.append(menu);
menu.classList.remove("hidden");
const left = Math.max(8, Math.min(clientX, window.innerWidth - menu.offsetWidth - 8));
const top = Math.max(8, Math.min(clientY, window.innerHeight - menu.offsetHeight - 8));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
$("cmap-tools-menu").setAttribute("aria-expanded", "true");
}
function cmapPlacementOptions() {
const context = cmapContextCreateContext || {};
const options = {};
if (context.point) {
options.x = Math.max(0, context.point.x - 70);
options.y = Math.max(0, context.point.y - 30);
}
if (context.parentSubmap) {
options.parentSubmap = context.parentSubmap;
options.submapDepth = context.parentSubmap.submapDepth + 1;
}
return options;
}
/**
* goal : Edit the selected concept presentation and synopsis.
* pre : One concept, page, submap or linking phrase is selected.
* post : A concept dialog or the inline phrase editor is opened.
*/
function editSelectedCmapNode(record = null) {
const prototype = cmapPrototypeState();
const selectedRecord = record || (prototype.editor ? prototype.editor.selected() : null);
if (!selectedRecord) {
window.alert(tr("select-one-concept", "Select one concept first."));
return;
}
if (selectedRecord.kind === "phrase") {
prototype.editor.editPhraseInline(selectedRecord);
return;
}
openCmapConceptDialog(selectedRecord);
}
function cmapKeyboardEditingTarget(target) {
return target instanceof Element && Boolean(
target.closest("input, textarea, select, [contenteditable='true']"));
}
function handleCmapKeyboardShortcut(event) {
if ($("cmap-view").classList.contains("hidden") || $("cmap-concept-dialog").open) return;
if (cmapKeyboardEditingTarget(event.target)) return;
const prototype = cmapPrototypeState();
const editor = prototype.editor;
if (!editor) return;
const commandKey = event.ctrlKey || event.metaKey;
const key = event.key.toLocaleLowerCase();
if (commandKey && !event.altKey && key === "a") {
event.preventDefault();
editor.selectAll();
return;
}
if (commandKey && !event.altKey && key === "c") {
if (editor.copySelectionReferences()) event.preventDefault();
return;
}
if (commandKey && !event.altKey && key === "v") {
if (editor.canPasteConceptReferences()) {
event.preventDefault();
editor.pasteConceptReferences();
}
return;
}
if (commandKey && !event.altKey && key === "z") {
event.preventDefault();
if (event.shiftKey) {
editor.redo();
} else {
editor.undo();
}
return;
}
if (commandKey && !event.altKey && !event.shiftKey && key === "y") {
event.preventDefault();
editor.redo();
return;
}
if (commandKey && !event.altKey && key === "g") {
event.preventDefault();
if (event.shiftKey) {
editor.ungroupSelection();
} else {
groupSelectedCmapItems();
}
return;
}
if (!commandKey && !event.altKey && !event.shiftKey &&
(event.key === "Delete" || event.key === "Backspace")) {
event.preventDefault();
editor.deleteSelection();
return;
}
if (!commandKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
editor.clearSelection();
return;
}
if (event.key === "F2" && !event.repeat && !commandKey && !event.altKey &&
editor.selectedAll().length === 1) {
event.preventDefault();
editor.editSelected();
}
}
/**
* goal : Add a labelled relation between two sample items.
* pre : source and target are records from the active prototype.
* post : source -> linking phrase -> target is visible.
* result : The new linking-phrase record.
*/
function connectCmapPrototypeNodes(source, target, label) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
return prototype.editor.connectWithPhrase(source, target, label || "?????", false);
}
function groupSelectedCmapItems() {
const prototype = cmapPrototypeState();
if (!prototype.editor || !prototype.editor.canGroupSelection()) return false;
const label = window.prompt(
tr("group-submap-name", "Name of the main concept for the new sub-CMap"),
tr("sub-concept-map", "Sub concept map"));
if (!label || !label.trim()) return false;
return prototype.editor.groupSelection({
label: label.trim(),
childMap: label.trim(),
synopsis: tr("grouped-submap-synopsis", "Grouped sub-concept map.")
});
}
function populateCmapPrototypeSubmap(record, editor) {
const baseX = Number(record.node.attr("x"));
const baseY = Number(record.node.attr("y"));
const detail = editor.addSubmapItem(record, {
label: "Detail concept",
synopsis: "Concept inside the expanded submap.",
x: baseX + 315,
y: baseY + 170,
backgroundColor: "#fff4cf",
borderColor: "#a97c00"
});
editor.connectWithPhrase(record, detail, "contains", false);
if (record.submapDepth < 2) {
const nested = editor.addSubmapItem(record, {
label: "Nested submap",
synopsis: "This submap can also be expanded.",
kind: "submap",
childMap: `${record.childMap || record.label}/nested`,
x: baseX + 60,
y: baseY + 310,
backgroundColor: "#edf7e8",
borderColor: "#57834a"
});
editor.connectWithPhrase(detail, nested, "contains", false);
}
}
/**
* goal : Build a small editable CmapTools-like sample around the current page.
* pre : The bundled racket-wiki cmap component is loaded.
* post : Concepts can be selected, resized and linked by dragging the relation handle.
*/
function resetCmapPrototype(cmapDocument = null, includeSample = true) {
const canvas = $("cmap-canvas");
cancelCmapAutosave();
state.cmapSavedSnapshot = null;
closeCmapContextMenu();
$("cmap-map-navigation").classList.add("hidden");
$("cmap-active-map-title").textContent = "";
if (state.cmapPrototype && state.cmapPrototype.editor &&
typeof state.cmapPrototype.editor.destroy === "function") {
state.cmapPrototype.editor.destroy();
}
canvas.replaceChildren();
state.cmapPrototype = null;
console.info("[racket-wiki:cmap-host 0.2.99] resetCmapPrototype", {
cmapAvailable: typeof window.Cmap === "function",
interactionLayerAvailable: Boolean(window.RacketWikiCmap),
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
cmapStylesheet: Array.from(document.styleSheets)
.map((sheet) => sheet.href)
.find((href) => href && href.includes("/cmap/cmap.css")) || null
});
if (typeof window.Cmap !== "function" || !window.RacketWikiCmap) {
const message = document.createElement("p");
message.className = "error";
message.textContent = "The bundled cmap component could not be loaded.";
canvas.append(message);
return;
}
const prototype = cmapPrototypeState();
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
Cmap: window.Cmap,
renderItem: (record) => cmapNodeHtml(record),
onOpenPage: (record) => {
if (record.pageSlug) {
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
}
},
onOpenCmap: (record) => {
if (record.cmapSlug) {
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
}
},
onOpenStoredSubMap: (record) => {
if (record.cmapSlug) {
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
}
},
onOpenBoundaryReference: () => {
const source = state.currentConceptMapSource;
if (source) {
navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error));
}
},
onOpenSubMap: (record) => {
console.info("[racket-wiki:cmap-host 0.2.99] submap state changed", {
id: record.id,
expanded: record.expanded,
childMap: record.childMap
});
},
onPopulateSubMap: (record, editor) => populateCmapPrototypeSubmap(record, editor),
onConfirmDetachFromSubmap: (record, parent) => window.confirm(
tr("detach-submap-confirm", "Place \"{concept}\" outside submap \"{submap}\"?")
.replace("{concept}", record.label)
.replace("{submap}", parent.label)),
onSelectionChange: (record, connector, selectedRecords) => {
const selected = Array.isArray(selectedRecords) ? selectedRecords : (record ? [record] : []);
$("cmap-promote-submap").disabled = !(selected.length === 1 &&
record && record.kind === "submap" && !record.cmapSlug);
$("cmap-promote-submap").title = "";
$("cmap-edit-selected").disabled = selected.length !== 1;
$("cmap-group-selected").disabled = !prototype.editor.canGroupSelection();
$("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection();
$("cmap-hide-selected").disabled = !prototype.editor.canHideSelectionInCurrentContext();
$("cmap-delete-selected").disabled = selected.length === 0 && !connector;
$("cmap-copy-selected").disabled = !selected.some((item) =>
item.conceptId && item.kind !== "phrase" && item.kind !== "submap");
$("cmap-paste-concepts").disabled = !prototype.editor.canPasteConceptReferences();
},
onHistoryChange: ({ canUndo, canRedo }) => {
$("cmap-undo").disabled = !canUndo;
$("cmap-redo").disabled = !canRedo;
scheduleCmapAutosave();
},
onMapChange: (mapReference) => {
$("cmap-map-navigation").classList.toggle("hidden", !mapReference);
$("cmap-active-map-title").textContent = mapReference ? mapReference.title : "";
restoreCmapZoom();
renderHiddenCmapItems();
},
onVisibilityChange: () => renderHiddenCmapItems(),
onEditItem: (record) => editSelectedCmapNode(record),
onCreateConnectedItem: (context) => openNewCmapConceptDialog(context),
createRelationLabel: tr("create-relation", "Create relation"),
editConceptLabel: tr("edit-concept", "Edit concept"),
resizeConceptLabel: tr("resize-concept", "Resize concept"),
relationLabel: tr("relation", "Relation")
});
restoreCmapZoom();
console.info("[racket-wiki:cmap-host 0.2.99] editor stored", {
editorAvailable: Boolean(prototype.editor),
canvasChildCount: canvas.children.length
});
if (cmapDocument) {
prototype.editor.loadDocument(cmapDocument);
renderHiddenCmapItems();
return;
}
if (!includeSample) {
prototype.editor.resetHistory();
return;
}
const page = state.currentPage || state.pages[0] || null;
const pageNode = addCmapPrototypeNode({
label: page ? page.title : state.siteTitle,
synopsis: page ? currentPageSynopsis() : "Wiki page concept",
kind: "page",
pageSlug: page ? page.slug : null,
x: 365,
y: 220,
width: 270,
height: 125,
backgroundColor: "#e7f2fb",
borderColor: "#4479a1"
});
const conceptNode = addCmapPrototypeNode({
label: tr("context", "Context"),
synopsis: "A free concept without a wiki page.",
x: 70,
y: 120,
backgroundColor: "#fff4cf",
borderColor: "#a97c00"
});
const subMapNode = addCmapPrototypeNode({
label: tr("sub-concept-map", "Sub concept map"),
synopsis: "Placeholder for an expandable child map.",
kind: "submap",
childMap: "prototype-child",
x: 690,
y: 360,
backgroundColor: "#edf7e8",
borderColor: "#57834a"
});
connectCmapPrototypeNodes(conceptNode, pageNode, "describes");
connectCmapPrototypeNodes(pageNode, subMapNode, "contains");
prototype.editor.clearSelection();
prototype.editor.resetHistory();
}
/**
* goal : Open the persistent CMap workspace without changing wiki pages.
* pre : User can read the wiki frontend.
* post : The selected stored CMap or the unsaved starter map is displayed.
*/
function renderConceptMapSelector() {
const input = $("cmap-map-select");
input.placeholder = state.conceptMaps.length ?
tr("select-concept-map", "Select a CMap") :
tr("no-concept-maps", "No saved CMaps");
cmapMapCombobox.setOptions(
state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)),
state.currentConceptMap ? state.currentConceptMap.slug : "");
const hasStoredMap = Boolean(state.currentConceptMap);
const referenceButton = $("cmap-wiki-reference");
referenceButton.classList.toggle("hidden", !hasStoredMap);
if (hasStoredMap) {
referenceButton.textContent = `${state.currentConceptMap.title} · cmap:${state.currentConceptMap.slug}`;
referenceButton.dataset.markdown =
`[${escapeMarkdownLinkLabel(state.currentConceptMap.title)}](cmap:${state.currentConceptMap.slug})`;
} else {
referenceButton.textContent = "";
delete referenceButton.dataset.markdown;
}
$("cmap-rename-map").disabled = !hasStoredMap;
$("cmap-set-start-map").disabled = !hasStoredMap;
$("cmap-set-start-map").textContent = hasStoredMap &&
startCmapSlug() === state.currentConceptMap.slug ?
tr("start-concept-map", "Start CMap") :
tr("set-start-concept-map", "Use as start CMap");
$("cmap-delete-map").disabled = !hasStoredMap;
$("cmap-create-snapshot").disabled = !hasStoredMap;
$("cmap-history").disabled = !hasStoredMap;
}
async function loadConceptMaps() {
const [result, usageResult] = await Promise.all([
api("/api/cmaps"),
api("/api/cmaps/concept-usage").catch((error) => {
console.warn("[racket-wiki:cmap-host 0.2.99] concept usage is unavailable; CMaps continue without global counts", error);
return { placements: [] };
})
]);
state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : [];
state.cmapConceptUsage = new Map();
state.cmapPageConcepts = new Map();
const placements = Array.isArray(usageResult.placements) ? usageResult.placements : [];
const pageByConcept = new Map();
for (const placement of placements) {
if (!placement?.conceptId || !placement.pageSlug) continue;
pageByConcept.set(
placement.conceptId,
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
}
for (const placement of placements) {
if (!placement || !placement.conceptId || !placement.cmapSlug) continue;
if (!state.cmapConceptUsage.has(placement.conceptId)) {
state.cmapConceptUsage.set(placement.conceptId, new Map());
}
state.cmapConceptUsage.get(placement.conceptId)
.set(placement.cmapSlug, Number(placement.count) || 0);
const pageKey = pageByConcept.get(placement.conceptId);
if (!pageKey) continue;
if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map());
const pageConcepts = state.cmapPageConcepts.get(pageKey);
if (!pageConcepts.has(placement.conceptId)) {
pageConcepts.set(placement.conceptId, {
conceptId: placement.conceptId,
label: placement.label || tr("concept", "Concept"),
count: 0,
maps: new Map()
});
}
const concept = pageConcepts.get(placement.conceptId);
const count = Number(placement.count) || 0;
concept.count += count;
const existingMap = concept.maps.get(placement.cmapSlug);
concept.maps.set(placement.cmapSlug, {
slug: placement.cmapSlug,
title: placement.cmapTitle || placement.cmapSlug,
count: count + (existingMap?.count || 0)
});
}
state.graphData = null;
renderConceptMapSelector();
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
if (state.currentPage) renderPageCmapConnections(state.currentPage);
return state.conceptMaps;
}
function normalizeCmapConceptIdentities(documentValue, cmapSlug) {
const legacyIds = new Map();
const normalize = (conceptId) => {
if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId;
if (!legacyIds.has(conceptId)) legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`);
return legacyIds.get(conceptId);
};
for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
concept.id = normalize(concept.id);
}
for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) {
item.conceptId = normalize(item.conceptId);
}
return documentValue;
}
function decodeStoredConceptMapDocument(conceptMap) {
let documentValue = conceptMap.document;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
console.error("[racket-wiki:cmap-host 0.2.99] invalid stored CMap document", {
slug: conceptMap.slug,
valueType: Array.isArray(documentValue) ? "array" : typeof documentValue,
value: documentValue
});
throw new Error("The stored CMap document is not a JSON object.");
}
return normalizeCmapConceptIdentities(documentValue, conceptMap.slug || "");
}
function currentCmapSnapshot() {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
return JSON.stringify(prototype.editor.toDocument());
}
function currentCmapStorageMap() {
return state.currentConceptMapSource || state.currentConceptMap;
}
function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) {
state.cmapSavedSnapshot = snapshot;
}
function cmapHasUnsavedChanges() {
if ($("cmap-view").classList.contains("hidden")) return false;
const currentSnapshot = currentCmapSnapshot();
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
currentSnapshot !== state.cmapSavedSnapshot;
}
function cancelCmapAutosave() {
if (cmapAutosaveTimer !== null) {
window.clearTimeout(cmapAutosaveTimer);
cmapAutosaveTimer = null;
}
}
function scheduleCmapAutosave() {
cancelCmapAutosave();
if (!can("editor") || !state.currentConceptMap || !cmapHasUnsavedChanges()) return;
showCmapStatus(tr("autosave-pending", "Changes waiting to be saved"));
cmapAutosaveTimer = window.setTimeout(() => {
cmapAutosaveTimer = null;
saveStoredConceptMap({ automatic: true }).catch((error) => console.error(error));
}, CMAP_AUTOSAVE_DELAY);
}
function updateStoredConceptMapSummary(conceptMap) {
const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
if (index >= 0) {
state.conceptMaps[index] = { ...state.conceptMaps[index], ...conceptMap };
} else {
state.conceptMaps.push(conceptMap);
}
renderConceptMapSelector();
}
function continueCmapTransition() {
const transition = pendingCmapTransition;
if (!transition) return;
pendingCmapTransition = null;
$("cmap-unsaved-dialog").close();
Promise.resolve()
.then(transition.action)
.then(() => transition.resolve(true))
.catch((error) => {
transition.reject(error);
});
}
function discardCmapChangesAndContinue() {
markCurrentCmapSaved();
continueCmapTransition();
}
function cancelCmapTransition() {
const transition = pendingCmapTransition;
pendingCmapTransition = null;
$("cmap-unsaved-dialog").close();
if (transition) transition.resolve(false);
renderConceptMapSelector();
}
function requestCmapTransition(action) {
if (!cmapHasUnsavedChanges()) {
return Promise.resolve()
.then(action)
.then(() => true);
}
if (pendingCmapTransition) return Promise.resolve(false);
return new Promise((resolve, reject) => {
pendingCmapTransition = { action, resolve, reject };
$("cmap-unsaved-dialog").showModal();
});
}
async function openStoredConceptMap(slug) {
if (!slug) return;
const loadSequence = ++state.cmapLoadSequence;
showCmapStatus(tr("loading", "Loading…"));
const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`);
if (loadSequence !== state.cmapLoadSequence) return;
conceptMap.document = decodeStoredConceptMapDocument(conceptMap);
const derivedView = conceptMap.document.derivedView;
let sourceMap = null;
let editorDocument = conceptMap.document;
if (derivedView && typeof derivedView === "object" &&
typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug &&
Number.isInteger(Number(derivedView.rootItemId))) {
sourceMap = await api(`/api/cmaps/${encodeURIComponent(derivedView.sourceCmapSlug)}`);
if (loadSequence !== state.cmapLoadSequence) return;
sourceMap.document = decodeStoredConceptMapDocument(sourceMap);
editorDocument = sourceMap.document;
}
console.info("[racket-wiki:cmap-host 0.2.99] stored CMap received", {
slug: conceptMap.slug,
version: conceptMap.currentVersion,
itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0,
connectorCount: Array.isArray(conceptMap.document.connectors) ? conceptMap.document.connectors.length : 0
});
state.currentConceptMap = conceptMap;
state.currentConceptMapSource = sourceMap;
renderConceptMapSelector();
resetCmapPrototype(editorDocument, false);
if (sourceMap) {
const editor = cmapPrototypeState().editor;
const root = editor.items.find((item) =>
Number(item.id) === Number(derivedView.rootItemId));
if (!root || root.kind !== "submap") {
throw new Error("The source sub-CMap no longer exists.");
}
root.separateMap = true;
root.cmapSlug = conceptMap.slug;
root.childMap = conceptMap.title;
if (!root.mapReference) {
root.mapReference = {
id: `cmap-${root.id}`,
title: conceptMap.title,
rootItemId: root.id,
itemIds: editor.items.filter((item) => editor.isDescendantOf(item, root))
.map((item) => item.id)
};
editor.conceptMaps.set(root.mapReference.id, root.mapReference);
}
editor.openSubmapMap(root);
}
markCurrentCmapSaved();
const loadedEditor = cmapPrototypeState().editor;
console.info("[racket-wiki:cmap-host 0.2.99] stored CMap loaded", {
slug: conceptMap.slug,
editorAvailable: Boolean(loadedEditor),
itemCount: loadedEditor ? loadedEditor.items.length : 0,
connectorCount: loadedEditor ? loadedEditor.connectors.length : 0
});
showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true);
}
async function loadHistoricalConceptMapVersion(version) {
const conceptMap = currentCmapStorageMap();
if (!conceptMap) return;
const historical = await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`);
historical.document = decodeStoredConceptMapDocument(historical);
const currentSnapshot = JSON.stringify(conceptMap.document);
resetCmapPrototype(historical.document, false);
state.cmapSavedSnapshot = currentSnapshot;
showCmapStatus(
tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
.replace("{version}", String(historical.version)),
true);
}
async function showConceptMapHistory() {
const conceptMap = currentCmapStorageMap();
if (!conceptMap) return;
const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`);
const list = $("cmap-history-list");
list.replaceChildren();
const versions = result.versions || [];
if (!versions.length) {
const empty = document.createElement("p");
empty.className = "muted cmap-history-empty";
empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet.");
list.append(empty);
}
for (const version of versions) {
const row = document.createElement("div");
row.className = "cmap-history-row";
const label = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = `${tr("version", "Version")} ${version.version}${version.title}`;
const meta = document.createElement("div");
meta.className = "muted";
const knownSummaries = {
create: tr("concept-map-created-version", "CMap created"),
rename: tr("concept-map-renamed-version", "CMap renamed")
};
let summary = knownSummaries[version.action] || version.summary;
if (version.action === "snapshot") {
const snapshotLabel = tr("snapshot", "Snapshot");
if (version.summary === "Current state when CMap history was enabled") {
summary = tr("concept-map-initial-version", "Initial available version");
} else {
summary = version.summary === snapshotLabel ? snapshotLabel : `${snapshotLabel}${version.summary}`;
}
}
if (version.summary === "Automatic save") summary = tr("automatic-save", "Automatic save");
if (version.summary === "Manual save") summary = tr("manual-save", "Manual save");
meta.textContent = `${pageDisplayDate(version.createdAt)} · ${version.author} · ${summary}`;
label.append(heading, document.createElement("br"), meta);
const load = document.createElement("button");
load.type = "button";
load.textContent = version.version === conceptMap.currentVersion ?
tr("current-version", "Current") : tr("load-version", "Load version");
load.disabled = version.version === conceptMap.currentVersion;
load.addEventListener("click", () => {
$("cmap-history-dialog").close();
requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version))
.catch((error) => showCmapStatus(error.message));
});
const actions = document.createElement("div");
actions.className = "cmap-history-actions";
actions.append(load);
if (can("editor")) {
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = tr("delete-history-item", "Delete");
remove.addEventListener("click", async () => {
const question = tr(
"delete-concept-map-history-confirm",
"Delete CMap history version {version}? The current CMap will not be changed.")
.replace("{version}", String(version.version));
if (!window.confirm(question)) return;
remove.disabled = true;
try {
await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version.version)}`,
{ method: "DELETE" });
row.remove();
if (!list.querySelector(".cmap-history-row")) {
const empty = document.createElement("p");
empty.className = "muted cmap-history-empty";
empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet.");
list.append(empty);
}
showCmapStatus(tr("concept-map-history-deleted", "History item deleted"), true);
} catch (error) {
remove.disabled = false;
showCmapStatus(error.message);
}
});
actions.append(remove);
}
row.append(label, actions);
list.append(row);
}
$("cmap-history-dialog").showModal();
}
async function createStoredConceptMap() {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return;
const conceptMap = await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({
title: title.trim(),
document: { schemaVersion: 1, items: [], connectors: [], conceptMaps: [] }
})
});
await loadConceptMaps();
location.hash = cmapRoute(conceptMap.slug);
}
async function promoteSelectedSubmapToStoredMap() {
const prototype = cmapPrototypeState();
const editor = prototype.editor;
const record = editor ? editor.selected() : null;
if (!record || record.kind !== "submap") return false;
if (record.cmapSlug) {
await navigateToHash(cmapRoute(record.cmapSlug));
return true;
}
const title = window.prompt(
tr("submap-name", "Name of the new concept map"),
record.childMap || record.label);
if (!title || !title.trim()) return false;
const button = $("cmap-promote-submap");
button.disabled = true;
try {
const sourceSaved = await saveStoredConceptMap({
force: true,
snapshotVersion: true,
summary: tr("before-submap-extraction", "Before creating shared sub-CMap view")
});
if (!sourceSaved) return false;
const sourceMap = currentCmapStorageMap();
if (!sourceMap) return false;
const created = await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({
title: title.trim(),
document: {
schemaVersion: 2,
derivedView: {
sourceCmapSlug: sourceMap.slug,
rootItemId: record.id
},
concepts: [],
items: [],
connectors: [],
conceptMaps: []
}
})
});
editor.promoteSubmap(record, title.trim());
editor.updateItem(record, { cmapSlug: created.slug });
const parentSaved = await saveStoredConceptMap({
force: true,
historyMode: "autosave",
summary: tr("submap-derived", "Standalone view created for sub-CMap")
});
if (!parentSaved) {
showCmapStatus(tr("submap-created-parent-unsaved",
"The new CMap was created, but the parent CMap still needs to be saved."));
return false;
}
location.hash = cmapRoute(created.slug);
return true;
} finally {
const selected = editor.selected();
button.disabled = !(selected && selected.kind === "submap" && !selected.cmapSlug);
}
}
async function renameStoredConceptMap() {
cancelCmapAutosave();
if (cmapHasUnsavedChanges() && !await saveStoredConceptMap({ automatic: true })) return false;
const conceptMap = state.currentConceptMap;
if (!conceptMap) return false;
const title = window.prompt(
tr("rename-concept-map", "Rename CMap"),
conceptMap.title);
if (!title || !title.trim() || title.trim() === conceptMap.title) return false;
try {
state.currentConceptMap = await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/rename`,
{
method: "POST",
body: JSON.stringify({
title: title.trim(),
baseVersion: conceptMap.currentVersion
})
});
await loadConceptMaps();
showCmapStatus(tr("concept-map-renamed", "CMap renamed"), true);
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
}
async function deleteStoredConceptMap() {
cancelCmapAutosave();
if (cmapSavePromise) await cmapSavePromise;
const conceptMap = state.currentConceptMap;
if (!conceptMap) return false;
const question = tr(
"delete-concept-map-confirm",
"Delete concept map \"{title}\"? Links to it will remain but will no longer open the map.")
.replace("{title}", conceptMap.title);
if (!window.confirm(question)) return false;
try {
await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { method: "DELETE" });
markCurrentCmapSaved();
state.currentConceptMap = null;
state.currentConceptMapSource = null;
await loadConceptMaps();
const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps";
await navigateToHash(target);
showCmapStatus(tr("concept-map-deleted", "CMap deleted"), true);
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
}
async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false,
historyMode = null } = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return false;
cancelCmapAutosave();
if (automatic && !currentCmapStorageMap()) return false;
const effectiveHistoryMode = historyMode ||
(snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual"));
if (cmapSavePromise) {
const firstSaveSucceeded = await cmapSavePromise;
if (!firstSaveSucceeded) return false;
if (force || cmapHasUnsavedChanges() || effectiveHistoryMode !== "autosave") {
return saveStoredConceptMap({ automatic, force, summary, snapshotVersion, historyMode });
}
return true;
}
const snapshot = currentCmapSnapshot();
if (snapshot === null) return false;
if (!force && snapshot === state.cmapSavedSnapshot && effectiveHistoryMode === "autosave") {
return true;
}
const document = JSON.parse(snapshot);
const conceptMapAtStart = currentCmapStorageMap();
let saveSucceeded = false;
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")));
cmapSavePromise = (async () => {
try {
if (!conceptMapAtStart) {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) {
showCmapStatus("");
return false;
}
state.currentConceptMap = await api("/api/cmaps", {
method: "POST",
body: JSON.stringify({ title: title.trim(), document })
});
const savedRoute = cmapRoute(state.currentConceptMap.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
await loadConceptMaps();
} else {
const savedConceptMap = await api(
`/api/cmaps/${encodeURIComponent(conceptMapAtStart.slug)}`,
{
method: "PUT",
body: JSON.stringify({
title: conceptMapAtStart.title,
baseVersion: conceptMapAtStart.currentVersion,
summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode,
document
})
});
if (state.currentConceptMapSource &&
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
state.currentConceptMapSource = savedConceptMap;
updateStoredConceptMapSummary(savedConceptMap);
} else if (state.currentConceptMap &&
state.currentConceptMap.slug === conceptMapAtStart.slug) {
state.currentConceptMap = savedConceptMap;
updateStoredConceptMapSummary(savedConceptMap);
}
}
markCurrentCmapSaved(snapshot);
await loadConceptMaps();
showCmapStatus(
snapshotVersion ? tr("snapshot-created", "Snapshot created") :
(automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")),
true);
saveSucceeded = true;
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
})().finally(() => {
cmapSavePromise = null;
if (saveSucceeded && cmapHasUnsavedChanges()) scheduleCmapAutosave();
});
return cmapSavePromise;
}
async function createConceptMapSnapshot() {
if (!currentCmapStorageMap()) return false;
const description = window.prompt(tr("snapshot-description", "Snapshot description"), "");
if (description === null) return false;
return saveStoredConceptMap({
force: true,
summary: description.trim() || tr("snapshot", "Snapshot"),
snapshotVersion: true
});
}
async function showCmapPrototype(requestedSlug = null) {
state.previousView = state.currentPage ? "page-view" : "cmap-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("cmaps", "CMaps") }
]);
renderToc([], () => {});
show("cmap-view");
state.cmapGuardHash = location.hash;
await loadConceptMaps();
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
await openStoredConceptMap(requestedSlug);
} else if (requestedSlug) {
state.currentConceptMap = null;
state.currentConceptMapSource = null;
resetCmapPrototype(null, false);
markCurrentCmapSaved();
renderConceptMapSelector();
showCmapStatus(tr("concept-map-not-found", "CMap not found"));
} else if (state.conceptMaps.length) {
const preferred = startCmapSlug();
const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) ||
state.conceptMaps[0];
await openStoredConceptMap(target.slug);
} else {
state.currentConceptMap = null;
state.currentConceptMapSource = null;
resetCmapPrototype();
markCurrentCmapSaved();
renderConceptMapSelector();
}
}
////////////////////////////////////////////////////////////////////////////////
// Wiki graph support
////////////////////////////////////////////////////////////////////////////////
function graphNodeId(type, slug) {
return `${type}\u0000${slug}`;
}
function pageLinks(markdown) {
const container = document.createElement("div");
container.innerHTML = renderMarkdown(markdown || "");
const pages = new Set();
const conceptMaps = new Set();
for (const link of container.querySelectorAll("a[href]")) {
const href = link.getAttribute("href");
const cmapSlug = cmapSlugFromHref(href);
if (cmapSlug) {
conceptMaps.add(cmapSlug);
continue;
}
const pageSlug = wikiSlugFromHref(href);
if (pageSlug) pages.add(pageSlug);
}
for (const embed of container.querySelectorAll(".rw-cmap-embed[data-cmap-reference]")) {
const target = cmapMentionTarget(embed.dataset.cmapReference || "");
if (target) conceptMaps.add(target.slug);
}
return { pages, conceptMaps };
}
function conceptMapLinks(documentValue) {
const pages = new Set();
const conceptMaps = new Set();
const items = documentValue && Array.isArray(documentValue.items) ? documentValue.items : [];
for (const item of items) {
if (item && typeof item.pageSlug === "string" && item.pageSlug) pages.add(item.pageSlug);
if (item && typeof item.cmapSlug === "string" && item.cmapSlug) conceptMaps.add(item.cmapSlug);
}
return { pages, conceptMaps };
}
/**
* goal : Build one navigation graph from wiki pages and stored CMaps.
* pre : Page and CMap catalogues have been loaded.
* post : The graph is cached until either catalogue is reloaded.
* result : Typed graph nodes and directed page/CMap edges.
*/
async function loadGraphData() {
if (state.graphData) return state.graphData;
const pageSlugs = new Set(state.pages.map((page) => page.slug));
const cmapSlugs = new Set(state.conceptMaps.map((conceptMap) => conceptMap.slug));
const nodes = [
...state.pages.map((page) => ({
...page,
id: graphNodeId("page", page.slug),
type: "page"
})),
...state.conceptMaps.map((conceptMap) => ({
...conceptMap,
id: graphNodeId("cmap", conceptMap.slug),
type: "cmap"
}))
];
const edges = [];
const seen = new Set();
const addEdge = (fromType, fromSlug, toType, toSlug) => {
const from = graphNodeId(fromType, fromSlug);
const to = graphNodeId(toType, toSlug);
if (from === to) return;
const key = `${from}\u0001${to}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({ from, to });
};
for (const page of state.pages) {
const full = await api(`/api/pages/${encodeURIComponent(page.slug)}`);
const links = pageLinks(full.markdown);
for (const target of links.pages) {
const canonicalTarget = canonicalPageReference(target);
if (!pageSlugs.has(canonicalTarget) || canonicalTarget === page.slug) continue;
addEdge("page", page.slug, "page", canonicalTarget);
}
for (const target of links.conceptMaps) {
if (cmapSlugs.has(target)) addEdge("page", page.slug, "cmap", target);
}
}
for (const conceptMap of state.conceptMaps) {
const full = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`);
const links = conceptMapLinks(decodeStoredConceptMapDocument(full));
for (const target of links.pages) {
const canonicalTarget = canonicalPageReference(target);
if (pageSlugs.has(canonicalTarget)) addEdge("cmap", conceptMap.slug, "page", canonicalTarget);
}
for (const target of links.conceptMaps) {
if (cmapSlugs.has(target)) addEdge("cmap", conceptMap.slug, "cmap", target);
}
}
state.graphData = { nodes, edges };
return state.graphData;
}
/**
* goal : Select the direct incoming and outgoing context of one page.
* pre : data is a complete graph and focusId identifies a typed graph node.
* post : No application state is changed.
* result : A smaller graph containing focus, direct neighbours and directed edges.
*/
function contextGraphData(data, focusId) {
const relatedIds = new Set([focusId]);
const edges = [];
for (const edge of data.edges) {
if (edge.from === focusId || edge.to === focusId) {
relatedIds.add(edge.from);
relatedIds.add(edge.to);
edges.push(edge);
}
}
return {
nodes: data.nodes.filter((node) => relatedIds.has(node.id)),
edges
};
}
function graphColumnPositions(slugs, x, height) {
const positions = new Map();
if (slugs.length === 0) return positions;
const top = 90;
const bottom = height - 90;
const step = slugs.length === 1 ? 0 : (bottom - top) / (slugs.length - 1);
slugs.forEach((slug, index) => {
positions.set(slug, { x, y: slugs.length === 1 ? height / 2 : top + (step * index) });
});
return positions;
}
/**
* goal : Render a clickable directed SVG graph.
* pre : data contains graph nodes/edges using compact page references.
* post : The requested SVG is replaced; node clicks navigate to pages.
* result : none.
*/
function renderWikiGraph(data, focusId = null, svgId = "wiki-graph", layout = "circular") {
const svg = $(svgId);
svg.replaceChildren();
const width = 1000;
const height = 700;
const centerX = width / 2;
const centerY = height / 2;
const positions = new Map();
const nodes = data.nodes;
if (layout === "context" && focusId) {
const incoming = new Set();
const outgoing = new Set();
for (const edge of data.edges) {
if (edge.to === focusId && edge.from !== focusId) incoming.add(edge.from);
if (edge.from === focusId && edge.to !== focusId) outgoing.add(edge.to);
}
const shared = [...incoming].filter((slug) => outgoing.has(slug));
const incomingOnly = [...incoming].filter((slug) => !outgoing.has(slug));
const outgoingOnly = [...outgoing].filter((slug) => !incoming.has(slug));
positions.set(focusId, { x: centerX, y: centerY });
for (const [slug, position] of graphColumnPositions(incomingOnly, 180, height)) positions.set(slug, position);
for (const [slug, position] of graphColumnPositions(outgoingOnly, 820, height)) positions.set(slug, position);
if (shared.length > 0) {
const left = 320;
const right = 680;
const step = shared.length === 1 ? 0 : (right - left) / (shared.length - 1);
shared.forEach((slug, index) => positions.set(slug, { x: shared.length === 1 ? centerX : left + (step * index), y: 105 }));
}
} else {
const radius = Math.min(width, height) * 0.36;
nodes.forEach((node, index) => {
const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index / nodes.length) - Math.PI / 2;
positions.set(node.id, {
x: nodes.length <= 1 ? centerX : centerX + Math.cos(angle) * radius,
y: nodes.length <= 1 ? centerY : centerY + Math.sin(angle) * radius
});
});
}
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
const markerId = `${svgId}-arrow`;
marker.setAttribute("id", markerId);
marker.setAttribute("viewBox", "0 0 10 10");
marker.setAttribute("refX", "9");
marker.setAttribute("refY", "5");
marker.setAttribute("markerWidth", "7");
marker.setAttribute("markerHeight", "7");
marker.setAttribute("orient", "auto-start-reverse");
const markerPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
markerPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
marker.append(markerPath);
defs.append(marker);
svg.append(defs);
const edgeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
edgeLayer.setAttribute("class", "wiki-graph-edges");
for (const edge of data.edges) {
const from = positions.get(edge.from);
const to = positions.get(edge.to);
if (!from || !to) continue;
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", from.x);
line.setAttribute("y1", from.y);
line.setAttribute("x2", to.x);
line.setAttribute("y2", to.y);
line.setAttribute("marker-end", `url(#${markerId})`);
if (focusId && edge.to === focusId) line.classList.add("wiki-graph-edge-incoming");
if (focusId && edge.from === focusId) line.classList.add("wiki-graph-edge-outgoing");
edgeLayer.append(line);
}
svg.append(edgeLayer);
const nodeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
nodeLayer.setAttribute("class", "wiki-graph-nodes");
for (const node of nodes) {
const position = positions.get(node.id);
if (!position) continue;
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
const classes = ["wiki-graph-node", `wiki-graph-node-${node.type}`];
if (node.id === focusId) classes.push("wiki-graph-node-focus");
group.setAttribute("class", classes.join(" "));
group.setAttribute("transform", `translate(${position.x} ${position.y})`);
group.setAttribute("tabindex", "0");
group.setAttribute("role", "link");
group.setAttribute("aria-label", node.type === "cmap" ?
`${tr("concept-map", "Concept map")}: ${node.title}` :
`${tr("wiki-page", "Wiki page")}: ${node.title}`);
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", "11");
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = group.getAttribute("aria-label");
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
const incomingSide = layout === "context" && position.x < centerX;
text.setAttribute("x", incomingSide ? "-17" : "17");
text.setAttribute("y", "4");
if (incomingSide) text.setAttribute("text-anchor", "end");
text.textContent = node.title.length > 34 ? `${node.title.slice(0, 31)}` : node.title;
const open = () => {
closeContextOverlay();
navigateToHash(node.type === "cmap" ? cmapRoute(node.slug) : pageRoute(node.slug))
.catch((error) => console.error(error));
};
group.addEventListener("click", open);
group.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
group.append(title, circle, text);
nodeLayer.append(group);
}
svg.append(nodeLayer);
}
function contextSummary(data) {
const focusId = state.currentPage ? graphNodeId("page", state.currentPage.slug) : null;
let incoming = 0;
let outgoing = 0;
for (const edge of data.edges) {
if (edge.to === focusId) incoming += 1;
if (edge.from === focusId) outgoing += 1;
}
return tr("context-graph-direction-summary", "{incoming} incoming, {outgoing} outgoing")
.replace("{incoming}", String(incoming))
.replace("{outgoing}", String(outgoing));
}
/**
* goal : Open the complete wiki graph special view.
* pre : User can read pages.
* post : Full graph data is loaded and rendered.
*/
async function showGraph() {
closeContextOverlay();
state.previousView = state.currentPage ? "page-view" : "graph-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("graph", "Graph") }
]);
renderToc([], () => {});
show("graph-view");
$("graph-title").textContent = tr("combined-wiki-graph", "Wiki and CMap graph");
const data = await loadGraphData();
const pageCount = data.nodes.filter((node) => node.type === "page").length;
const cmapCount = data.nodes.filter((node) => node.type === "cmap").length;
$("graph-summary").textContent = tr("combined-graph-summary", "{pages} pages, {cmaps} concept maps, {links} links")
.replace("{pages}", String(pageCount))
.replace("{cmaps}", String(cmapCount))
.replace("{links}", String(data.edges.length));
renderWikiGraph(data);
}
/**
* goal : Load direct incoming and outgoing neighbours of the current page.
* pre : state.currentPage identifies an existing page.
* post : state.contextDockData contains the current direct context.
* result : The direct context graph.
*/
async function currentContextGraph() {
if (!state.currentPage) return { nodes: [], edges: [] };
const data = await loadGraphData();
const context = contextGraphData(data, graphNodeId("page", state.currentPage.slug));
state.contextDockData = context;
return context;
}
function closeContextOverlay() {
$("context-overlay").classList.add("hidden");
}
/**
* goal : Show the current page context as a modal overlay without leaving the page.
* pre : state.currentPage identifies an existing page.
* post : The overlay contains incoming and outgoing direct neighbours.
*/
async function showContextGraphOverlay() {
if (!state.currentPage) return;
const data = await currentContextGraph();
$("context-overlay-title").textContent = `${tr("context-graph", "Context graph")}: ${state.currentPage.title}`;
$("context-overlay-summary").textContent = contextSummary(data);
renderWikiGraph(data, graphNodeId("page", state.currentPage.slug), "context-overlay-graph", "context");
$("context-overlay").classList.remove("hidden");
}
/**
* goal : Dock the context graph beside or above the current page.
* pre : position is "right" or "top" and a current page exists.
* post : Page reading layout reserves space for the live context graph.
*/
async function dockContextGraph(position) {
if (!state.currentPage) return;
state.contextDockPosition = position;
closeContextOverlay();
await refreshContextDock();
}
async function refreshContextDock() {
if (!state.currentPage || !state.contextDockPosition) return;
const data = await currentContextGraph();
const layout = $("page-reading-layout");
layout.classList.remove("context-dock-right", "context-dock-top");
layout.classList.add(state.contextDockPosition === "top" ? "context-dock-top" : "context-dock-right");
$("context-dock").classList.remove("hidden");
$("context-dock-summary").textContent = contextSummary(data);
renderWikiGraph(data, graphNodeId("page", state.currentPage.slug), "context-dock-graph", "context");
}
function closeContextDock() {
state.contextDockPosition = null;
state.contextDockData = null;
$("context-dock").classList.add("hidden");
$("page-reading-layout").classList.remove("context-dock-right", "context-dock-top");
}
/**
* goal : Open the current direct context in the normal full-size graph view.
* pre : state.currentPage identifies an existing page.
* post : Context graph replaces the normal page view until another route is opened.
*/
async function showContextGraph() {
if (!state.currentPage) return;
closeContextOverlay();
const page = state.currentPage;
state.previousView = "page-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: page.title, href: pageRoute(page.slug) },
{ label: tr("context", "context") }
]);
renderToc([], () => {});
show("graph-view");
$("graph-title").textContent = `${tr("context-graph", "Context graph")}: ${page.title}`;
const data = await currentContextGraph();
$("graph-summary").textContent = contextSummary(data);
renderWikiGraph(data, graphNodeId("page", page.slug), "wiki-graph", "context");
}
function setConnectionStatus(status) {
const node = $("connection-status");
node.classList.remove("hidden", "online", "offline");
node.classList.add(status);
node.textContent = status === "offline" ? tr("offline", "Offline") : tr("online", "Online");
if (status === "online") {
window.setTimeout(() => node.classList.add("hidden"), 1800);
}
}
async function pingServer() {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch("/api/ping", { cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if ($("connection-status").classList.contains("offline")) setConnectionStatus("online");
} catch (_error) {
setConnectionStatus("offline");
} finally {
window.clearTimeout(timeout);
}
}
/**
* goal : Start periodic connectivity checks for the wiki backend.
* pre : Browser application initialization has completed.
* post : Offline/online state is updated approximately every 15 seconds.
*/
function startKeepAlive() {
window.setInterval(pingServer, 15000);
pingServer();
}
/**
* goal : Bootstrap the authenticated browser application.
* pre : index.html and all pinned vendor scripts are loaded.
* post : Session, translations, pages, editor, navigation and keep-alive are ready.
*/
async function initialize() {
state.session = await api("/api/session");
if (!state.session.authenticated) {
window.location.replace("/login");
return;
}
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationPage = translationData.page;
state.translationTemplate = translationData.template || "";
state.translations = translationData.translations || {};
applyTranslations();
restoreCmapPageGuides();
initializeEditor();
$("account-name").textContent = state.session.user.displayName;
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi();
await loadPages();
await loadConceptMaps();
loadBreadcrumbTrail();
await loadBookmarks();
await route();
startKeepAlive();
}
installWikiLinkNavigation();
$("search-form").addEventListener("submit", (event) => {
event.preventDefault();
const query = $("search-input").value.trim();
if (!query) {
const target = state.currentPage ? pageRoute(state.currentPage.slug) : pageRoute(startPage()?.slug || "start");
navigateToHash(target).catch((error) => console.error(error));
return;
}
navigateToHash(`#search/${encodeURIComponent(query)}`)
.catch((error) => console.error(error));
});
$("bookmark-page").addEventListener("click", (event) => {
event.preventDefault();
if (!state.currentPage) return;
const existing = bookmarkForSlug(state.currentPage.slug);
saveBookmark(state.currentPage.slug, existing?.section || "").catch((error) => console.error(error));
});
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("rename-page").addEventListener("click", (event) => { event.preventDefault(); beginRenamePage(); });
$("save-rename").addEventListener("click", () => { saveRenamePage().catch(console.error); });
$("cancel-rename").addEventListener("click", () => { if (state.currentPage) openPage(state.currentPage.slug).catch(console.error); });
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
$("wiki-brand").addEventListener("click", (event) => {
event.preventDefault();
goHome().catch((error) => console.error(error));
});
$("breadcrumbs").addEventListener("click", (event) => {
const link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
if (!link) return;
event.preventDefault();
const slug = link.dataset.breadcrumbSlug;
if (link.dataset.home === "true") {
goHome().catch((error) => console.error(error));
return;
}
truncateBreadcrumbTrail(slug);
const targetHash = `#/${encodeURIComponent(slug)}`;
if (location.hash === targetHash) {
openPage(slug).catch((error) => console.error(error));
} else {
location.hash = targetHash;
}
});
$("recent-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#recent").catch((error) => console.error(error));
});
$("bookmarks-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#bookmarks").catch((error) => console.error(error));
});
$("todo-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#todos").catch((error) => console.error(error));
});
$("graph-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#graph").catch((error) => console.error(error));
});
$("cmaps-link").addEventListener("click", (event) => {
event.preventDefault();
const preferred = startCmapSlug();
const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) ||
state.conceptMaps[0] || null;
navigateToHash(target ? cmapRoute(target.slug) : "#cmaps")
.catch((error) => console.error(error));
});
function openSelectedConceptMap() {
const slug = cmapMapCombobox.value();
if (!slug) return;
if (state.currentConceptMap && slug === state.currentConceptMap.slug) return;
navigateToHash(cmapRoute(slug))
.then((changed) => {
if (!changed) renderConceptMapSelector();
})
.catch((error) => {
showCmapStatus(error.message);
console.error(error);
});
}
$("cmap-map-select").addEventListener("change", openSelectedConceptMap);
$("cmap-wiki-reference").addEventListener("click", async (event) => {
const markdown = event.currentTarget.dataset.markdown || "";
if (!markdown) return;
try {
await navigator.clipboard.writeText(markdown);
} catch (_error) {
const input = document.createElement("textarea");
input.value = markdown;
input.style.position = "fixed";
input.style.left = "-10000px";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
showCmapStatus(tr("wiki-link-copied", "Wiki link copied"));
});
$("cmap-set-start-map").addEventListener("click", () => {
if (!state.currentConceptMap) return;
setStartCmapSlug(state.currentConceptMap.slug);
renderConceptMapSelector();
showCmapStatus(tr("start-concept-map-set", "Start CMap set"), true);
closeCmapContextMenu();
});
$("cmap-new-map").addEventListener("click", () => {
requestCmapTransition(() => createStoredConceptMap())
.catch((error) => {
showCmapStatus(error.message);
});
});
$("cmap-save-map").addEventListener("click", () => saveStoredConceptMap());
$("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap());
$("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap());
$("cmap-create-snapshot").addEventListener("click", () => {
createConceptMapSnapshot().catch((error) => showCmapStatus(error.message));
});
$("cmap-history").addEventListener("click", () => {
showConceptMapHistory().catch((error) => showCmapStatus(error.message));
});
$("cmap-add-concept").addEventListener("click", () => {
openNewCmapConceptDialog(cmapContextCreateContext || {});
});
$("cmap-add-page").addEventListener("click", () => {
if (!state.currentPage) return;
addCmapPrototypeNode({ ...cmapPlacementOptions(), label: state.currentPage.title, synopsis: currentPageSynopsis(), kind: "page", pageSlug: state.currentPage.slug, backgroundColor: "#e7f2fb", borderColor: "#4479a1" });
});
$("cmap-add-submap").addEventListener("click", () => {
const label = window.prompt(tr("add-submap", "Add sub-CMap"), tr("sub-concept-map", "Sub concept map"));
if (!label) return;
addCmapPrototypeNode({ ...cmapPlacementOptions(), label, synopsis: "Expandable child-map placeholder.", kind: "submap", childMap: label, backgroundColor: "#edf7e8", borderColor: "#57834a" });
});
$("cmap-promote-submap").addEventListener("click", () => {
promoteSelectedSubmapToStoredMap().catch((error) => {
console.error(error);
showCmapStatus(error.message);
});
});
$("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode());
$("cmap-copy-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.copySelectionReferences();
});
$("cmap-paste-concepts").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.pasteConceptReferences();
});
$("cmap-undo").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.undo();
});
$("cmap-redo").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.redo();
});
$("cmap-select-all").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.selectAll();
});
$("cmap-group-selected").addEventListener("click", () => {
groupSelectedCmapItems();
});
$("cmap-ungroup-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.ungroupSelection();
});
$("cmap-hide-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.hideSelectionInCurrentContext();
});
$("cmap-delete-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.deleteSelection();
});
$("cmap-toggle-page-guides").addEventListener("click", () => {
const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true";
setCmapPageGuides(visible);
});
$("cmap-reset").addEventListener("click", () => {
if (state.currentConceptMap) {
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
.catch((error) => console.error(error));
} else {
resetCmapPrototype();
markCurrentCmapSaved();
}
});
$("cmap-map-back").addEventListener("click", () => {
if (state.currentConceptMapSource) {
navigateToHash(cmapRoute(state.currentConceptMapSource.slug))
.catch((error) => console.error(error));
return;
}
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.openParentMap();
});
$("cmap-canvas").addEventListener("contextmenu", (event) => {
event.preventDefault();
const prototype = cmapPrototypeState();
if (!prototype.editor) return;
const point = prototype.editor.canvasPoint(event);
openCmapContextMenu(event.clientX, event.clientY, {
point,
parentSubmap: prototype.editor.submapAtPoint(point)
});
});
$("cmap-canvas").addEventListener("pointerover", (event) => {
const button = event.target.closest(".rw-cmap-view-description");
if (button) showCmapDescriptionTooltip(button);
});
$("cmap-canvas").addEventListener("pointerout", (event) => {
if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip();
});
$("cmap-canvas").addEventListener("focusin", (event) => {
const button = event.target.closest(".rw-cmap-view-description");
if (button) showCmapDescriptionTooltip(button);
});
$("cmap-canvas").addEventListener("focusout", (event) => {
if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip();
});
$("cmap-tools-menu").addEventListener("click", (event) => {
const prototype = cmapPrototypeState();
if (!prototype.editor) return;
if (!$("cmap-context-menu").classList.contains("hidden")) {
closeCmapContextMenu();
return;
}
const buttonRect = event.currentTarget.getBoundingClientRect();
const canvasRect = $("cmap-canvas").getBoundingClientRect();
const point = prototype.editor.canvasPoint({
clientX: canvasRect.left + 160,
clientY: Math.max(canvasRect.top, buttonRect.bottom) + 80
});
openCmapContextMenu(buttonRect.left, buttonRect.bottom + 4, {
point,
parentSubmap: prototype.editor.submapAtPoint(point)
});
});
$("cmap-context-menu").addEventListener("click", () => closeCmapContextMenu());
document.addEventListener("pointerdown", (event) => {
if (!event.target.closest("#cmap-context-menu, #cmap-tools-menu")) closeCmapContextMenu();
if (!event.target.closest(".cmap-color-control")) {
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
}
});
$("cmap-zoom-out").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) - 10));
$("cmap-zoom-in").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) + 10));
$("cmap-zoom-reset").addEventListener("click", () => setCmapZoom(100));
$("cmap-zoom-percent").addEventListener("change", (event) => setCmapZoom(event.target.value));
$("cmap-concept-page").addEventListener("change", (event) => {
if (cmapPageCombobox.value()) cmapLinkCombobox.clear();
});
$("cmap-concept-cmap").addEventListener("change", (event) => {
if (cmapLinkCombobox.value()) cmapPageCombobox.clear();
});
for (const id of ["cmap-concept-page", "cmap-concept-cmap"]) {
$(id).addEventListener("input", (event) => event.target.setCustomValidity(""));
}
$("cmap-concept-description-page").addEventListener("input", (event) =>
event.target.setCustomValidity(""));
$("cmap-concept-description-link").addEventListener("click", () =>
$("cmap-concept-dialog").close());
installCmapColorPickers();
$("cmap-apply-style").addEventListener("click", applySelectedCmapStyle);
$("cmap-concept-image").addEventListener("change", (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const record = cmapDialogRecord;
cmapDialogImageRead = readCmapImage(file)
.then((imageSource) => {
if (cmapDialogRecord !== record) return;
cmapDialogImageSource = imageSource;
updateCmapImagePreview();
})
.catch((error) => {
console.error(error);
window.alert(error.message);
});
});
$("cmap-concept-image-remove").addEventListener("click", () => {
cmapDialogImageSource = "";
cmapDialogImageRead = Promise.resolve();
$("cmap-concept-image").value = "";
updateCmapImagePreview();
});
$("cmap-concept-cancel").addEventListener("click", () => $("cmap-concept-dialog").close());
$("cmap-concept-form").addEventListener("submit", async (event) => {
event.preventDefault();
await cmapDialogImageRead;
const record = cmapDialogRecord;
const createContext = cmapDialogCreateContext;
const prototype = cmapPrototypeState();
if ((!record && !createContext) || !prototype.editor) return;
const label = $("cmap-concept-label").value.trim();
if (!label) {
$("cmap-concept-label").focus();
return;
}
const fontSize = Math.max(6, Math.min(54, Number($("cmap-concept-font-size").value) || 11));
const synopsisFontSize = Math.max(6, Math.min(54,
Number($("cmap-concept-synopsis-font-size").value) || 9));
const pageInput = $("cmap-concept-page");
const cmapInput = $("cmap-concept-cmap");
const selectedPage = record && record.kind === "submap" ? "" : cmapPageCombobox.value();
const selectedCmap = record && record.kind === "submap" ? "" : cmapLinkCombobox.value();
const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage;
if (selectedPage === null && !linkedPage) {
pageInput.setCustomValidity(tr("invalid-new-page", "Enter a page title or valid wiki address."));
pageInput.reportValidity();
return;
}
if (selectedCmap === null) {
cmapInput.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
cmapInput.reportValidity();
return;
}
const linkedPageValue = linkedPage || null;
const descriptionInput = $("cmap-concept-description-page").value.trim();
const descriptionPage = descriptionInput ? newPageReference(descriptionInput) :
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
if (!descriptionPage) {
$("cmap-concept-description-page").setCustomValidity(
tr("invalid-description-page", "Enter a valid description page address."));
$("cmap-concept-description-page").reportValidity();
return;
}
const linkedCmapValue = selectedCmap || "";
const parentCmapLink = linkedCmapValue === "__parent__";
const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null;
const changes = {
kind: record && record.kind === "submap" ? "submap" : (linkedPageValue ? "page" : "concept"),
label,
synopsis: $("cmap-concept-synopsis").value,
aspects: $("cmap-concept-aspects").value.split(",")
.map((aspect) => aspect.trim()).filter(Boolean),
descriptionPageSlug: descriptionPage,
pageSlug: linkedPageValue,
cmapSlug: linkedCmap,
parentCmapLink,
imageSource: cmapDialogImageSource,
backgroundColor: cmapColorValue($("cmap-concept-background").value),
textColor: cmapColorValue($("cmap-concept-text-color").value, "#222222"),
fontFamily: $("cmap-concept-font-family").value || "Arial, Helvetica, sans-serif",
fontSize: `${fontSize}pt`,
fontWeight: $("cmap-concept-bold").checked ? "700" : "400",
fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
$("cmap-concept-synopsis-text-color").value, "#4d4d4d"),
synopsisFontFamily: $("cmap-concept-synopsis-font-family").value ||
"Arial, Helvetica, sans-serif",
synopsisFontSize: `${synopsisFontSize}pt`,
synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal"
};
if (!record || record.kind !== "submap") {
changes.borderColor = linkedPageValue ? "#4479a1" :
((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00");
} else {
changes.submapBackgroundColor = cmapColorValue($("cmap-submap-background").value, "#edf7e8");
changes.submapBorderColor = cmapColorValue($("cmap-submap-border").value, "#57834a");
}
if (record) {
prototype.editor.updateItem(record, changes);
prototype.editor.selectItem(record);
} else {
if (createContext.point) {
changes.x = Math.max(0, createContext.point.x - 70);
changes.y = Math.max(0, createContext.point.y - 30);
}
if (createContext.parentSubmap) {
changes.parentSubmap = createContext.parentSubmap;
changes.submapDepth = createContext.parentSubmap.submapDepth + 1;
}
const newRecord = addCmapPrototypeNode(changes);
if (createContext.source && newRecord) {
prototype.editor.finishRelation(createContext.source, newRecord);
} else if (newRecord) {
prototype.editor.selectItem(newRecord);
}
}
$("cmap-concept-dialog").close();
});
$("cmap-concept-dialog").addEventListener("close", () => {
cmapDialogRecord = null;
cmapDialogCreateContext = null;
cmapDialogImageSource = "";
cmapDialogImageRead = Promise.resolve();
});
$("wiki-cmap-link-cancel").addEventListener("click", () => $("wiki-cmap-link-dialog").close());
$("wiki-cmap-link-form").addEventListener("submit", (event) => {
event.preventDefault();
insertSelectedWikiCmapLink();
});
$("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed);
$("wiki-cmap-link-dialog").addEventListener("close", () => {
pendingWikiCmapLinkLabel = "";
wikiCmapLinkCombobox.clear();
});
$("cmap-unsaved-save").addEventListener("click", async () => {
if (await saveStoredConceptMap()) continueCmapTransition();
});
$("cmap-unsaved-discard").addEventListener("click", discardCmapChangesAndContinue);
$("cmap-unsaved-cancel").addEventListener("click", cancelCmapTransition);
$("cmap-unsaved-dialog").addEventListener("cancel", (event) => {
event.preventDefault();
cancelCmapTransition();
});
$("cmap-history-close").addEventListener("click", () => $("cmap-history-dialog").close());
$("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraphOverlay().catch((error) => console.error(error)); });
$("context-overlay-close").addEventListener("click", (event) => { event.preventDefault(); closeContextOverlay(); });
$("context-overlay").addEventListener("click", (event) => { if (event.target === $("context-overlay")) closeContextOverlay(); });
$("context-dock-right").addEventListener("click", (event) => { event.preventDefault(); dockContextGraph("right").catch(console.error); });
$("context-dock-top").addEventListener("click", (event) => { event.preventDefault(); dockContextGraph("top").catch(console.error); });
$("context-open-full").addEventListener("click", (event) => { event.preventDefault(); showContextGraph().catch(console.error); });
$("context-dock-close").addEventListener("click", (event) => { event.preventDefault(); closeContextDock(); });
$("context-dock-popup").addEventListener("click", (event) => { event.preventDefault(); showContextGraphOverlay().catch(console.error); });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeContextOverlay();
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "s" &&
!$("cmap-view").classList.contains("hidden")) {
event.preventDefault();
if (can("editor")) {
saveStoredConceptMap()
.then((saved) => {
if (saved && pendingCmapTransition) continueCmapTransition();
})
.catch((error) => console.error(error));
}
return;
}
handleCmapKeyboardShortcut(event);
});
$("logout-link").addEventListener("click", async (event) => {
event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("profile-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#profile").catch(console.error);
});
$("profile-form").addEventListener("submit", async (event) => {
event.preventDefault();
const newPassword = $("profile-new-password").value;
if (newPassword !== $("profile-repeat-password").value) {
$("profile-status").textContent = tr("passwords-do-not-match", "Passwords do not match.");
return;
}
try {
const result = await api("/api/profile", {
method: "PUT",
body: JSON.stringify({
displayName: $("profile-display-name").value,
email: $("profile-email").value,
currentPassword: $("profile-current-password").value,
newPassword
})
});
state.session = result.session;
$("account-name").textContent = state.session.user.displayName;
$("profile-current-password").value = "";
$("profile-new-password").value = "";
$("profile-repeat-password").value = "";
$("profile-status").textContent = tr("profile-saved", "Profile saved.");
} catch (error) {
$("profile-status").textContent = error.message;
}
});
$("admin-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("admin-users-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/users").catch(console.error);
});
$("admin-mail-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/mail").catch(console.error);
});
$("admin-aliases-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/aliases").catch(console.error);
});
$("admin-orphaned-uploads-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/orphaned-uploads").catch(console.error);
});
$("admin-translations-link").addEventListener("click", (event) => {
event.preventDefault();
const page = state.pages.find((item) => item.slug === state.translationPage);
if (page) {
location.hash = `#/${encodeURIComponent(state.translationPage)}`;
return;
}
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("translations", "Translations") }
]);
beginNewPage(state.translationPage);
});
$("close-history").addEventListener("click", () => show("page-view"));
$("close-orphaned-uploads").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-user-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-mail-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-alias-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("cancel-edit").addEventListener("click", () => {
if (state.currentPage) {
openPage(state.currentPage.slug);
return;
}
if (state.newPageSlug) {
state.newPageSlug = null;
location.hash = "";
return;
}
route();
});
$("save-page").addEventListener("click", savePage);
$("editor-namespace").addEventListener("input", updateEditorSlugInfo);
$("template-select").addEventListener("change", (event) => {
applyTemplate(event.target.value).catch((error) => {
$("save-status").textContent = error.message;
event.target.value = "";
});
});
$("file-input").addEventListener("change", (event) => {
uploadFiles(event.target.files).catch(() => {});
event.target.value = "";
});
document.addEventListener("click", (event) => {
if (!cmapHasUnsavedChanges()) return;
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!link || link.target === "_blank") return;
event.preventDefault();
event.stopImmediatePropagation();
requestCmapTransition(() => link.click()).catch((error) => console.error(error));
}, true);
window.addEventListener("beforeunload", (event) => {
if (!cmapHasUnsavedChanges()) return;
event.preventDefault();
event.returnValue = "";
});
$("new-user-form").addEventListener("submit", async (event) => {
event.preventDefault();
await api("/api/admin/users", {
method: "POST",
body: JSON.stringify({
username: $("new-username").value,
displayName: $("new-display-name").value || $("new-username").value,
email: $("new-email").value,
password: $("new-password").value,
role: $("new-role").value,
enabled: true
})
});
event.target.reset();
await loadUsersAdmin();
});
$("mail-settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/api/admin/mail-settings", {
method: "PUT",
body: JSON.stringify(mailSettingsFormData())
});
$("mail-smtp-password").value = "";
$("mail-password-help").textContent = tr("smtp-password-kept", "A password is stored; leave empty to keep it.");
$("mail-settings-status").textContent = tr("saved", "Saved");
} catch (error) {
$("mail-settings-status").textContent = error.message;
}
});
$("mail-test-button").addEventListener("click", async () => {
const button = $("mail-test-button");
const recipient = $("mail-test-recipient").value.trim();
button.disabled = true;
$("mail-settings-status").textContent = tr("sending-test-mail", "Sending test email…");
try {
await api("/api/admin/mail-settings/test", {
method: "POST",
body: JSON.stringify({
...mailSettingsFormData(),
recipient
})
});
$("mail-settings-status").textContent = tr("test-mail-sent", "Test email accepted by SMTP server");
} catch (error) {
console.error("SMTP test failed", error);
$("mail-settings-status").textContent = smtpErrorMessage(error);
} finally {
button.disabled = false;
}
});
$("mail-smtp-tls").addEventListener("change", () => {
const acceptUntrustedCertificates = $("mail-smtp-accept-untrusted-certificates");
acceptUntrustedCertificates.disabled = !$("mail-smtp-tls").checked;
if (acceptUntrustedCertificates.disabled) acceptUntrustedCertificates.checked = false;
});
window.addEventListener("hashchange", () => {
if (cmapHasUnsavedChanges()) {
const requestedHash = location.hash;
const previousHash = state.cmapGuardHash;
history.replaceState(history.state, "", `${location.pathname}${location.search}${previousHash}`);
requestCmapTransition(() => {
state.cmapGuardHash = requestedHash;
if (location.hash === requestedHash) {
return route();
}
location.hash = requestedHash;
return undefined;
}).catch(console.error);
return;
}
state.cmapGuardHash = location.hash;
route().catch(console.error);
});
initialize().catch((error) => {
console.error(error);
const main = $("main");
main.replaceChildren();
const message = document.createElement("div");
message.className = "card error";
message.textContent = error.message;
main.append(message);
});
})();