(() => { "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: [], currentConceptMap: 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; 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", "admin-view", "alias-admin-view", "user-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("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } /** * 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; } function pageRoute(reference) { return `#/${encodeURIComponent(reference)}`; } function cmapRoute(slug) { return `#cmap/${encodeURIComponent(slug)}`; } 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 `${label}: ${todoText}`; } return `${label}: ${todoText}`; }); }).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 = /(]*>)\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, /\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 = /(?= 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 renderMarkdown(markdown, pageSlug = null) { const withExplicitWikiLinks = expandNamespacedMarkdownLinks(markdown || ""); const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug); const withTodos = expandTodoMarkup(withWikiLinks, pageSlug); const html = easyMDE.markdown(withTodos); const withImages = applyImageWidthMarkup(html); return DOMPurify.sanitize(withImages); } 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 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 || []; 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); 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-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 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 = `${pageDisplayDate(version.createdAt)}
`; 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 = `
${renderMarkdown(full.markdown, state.currentPage?.slug)}
`; }); 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 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, 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, role, enabled, password, actions); list.append(row); } } 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(); 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() { 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/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 ? `` : ""; const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : ""; return `
${image}
${safeLabel}
${safeSynopsis ? `
${safeSynopsis}
` : ""}
`; } /** * 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) { const size = Number.parseFloat(value); if (!Number.isFinite(size)) return 11; if (/px$/i.test(value || "")) return size * 0.75; return size; } function selectCmapFont(fontFamily) { const select = $("cmap-concept-font-family"); 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-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); $("cmap-concept-font-size").value = String(Math.round(cmapFontSizeInPoints(record.fontSize))); $("cmap-concept-image").value = ""; populateCmapPageOptions(record); populateCmapLinkOptions(record); updateCmapImagePreview(); $("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-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-image").value = ""; populateCmapPageOptions({ kind: "concept", pageSlug: null }); populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false }); updateCmapImagePreview(); $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); } 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 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; 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 === "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.84] 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)); } }, onOpenSubMap: (record) => { console.info("[racket-wiki:cmap-host 0.2.84] 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.separateMap); $("cmap-edit-selected").disabled = selected.length !== 1; $("cmap-group-selected").disabled = !prototype.editor.canGroupSelection(); $("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection(); $("cmap-delete-selected").disabled = selected.length === 0 && !connector; }, 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(); }, 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.84] editor stored", { editorAvailable: Boolean(prototype.editor), canvasChildCount: canvas.children.length }); if (cmapDocument) { prototype.editor.loadDocument(cmapDocument); 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); $("cmap-rename-map").disabled = !hasStoredMap; $("cmap-delete-map").disabled = !hasStoredMap; } async function loadConceptMaps() { const result = await api("/api/cmaps"); state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : []; state.graphData = null; renderConceptMapSelector(); return state.conceptMaps; } 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.84] 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 documentValue; } function currentCmapSnapshot() { const prototype = cmapPrototypeState(); if (!prototype.editor) return null; return JSON.stringify(prototype.editor.toDocument()); } 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); console.info("[racket-wiki:cmap-host 0.2.84] 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; renderConceptMapSelector(); resetCmapPrototype(conceptMap.document, false); markCurrentCmapSaved(); const loadedEditor = cmapPrototypeState().editor; console.info("[racket-wiki:cmap-host 0.2.84] 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 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 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; 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 } = {}) { const prototype = cmapPrototypeState(); if (!prototype.editor) return false; cancelCmapAutosave(); if (automatic && !state.currentConceptMap) return false; if (cmapSavePromise) { const firstSaveSucceeded = await cmapSavePromise; if (!firstSaveSucceeded) return false; if (cmapHasUnsavedChanges()) return saveStoredConceptMap({ automatic }); return true; } const snapshot = currentCmapSnapshot(); if (snapshot === null) return false; if (snapshot === state.cmapSavedSnapshot) { if (!automatic) showCmapStatus(tr("concept-map-saved", "CMap saved"), true); return true; } const document = JSON.parse(snapshot); const conceptMapAtStart = state.currentConceptMap; let saveSucceeded = false; showCmapStatus(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, document }) }); if (state.currentConceptMap && state.currentConceptMap.slug === conceptMapAtStart.slug) { state.currentConceptMap = savedConceptMap; updateStoredConceptMapSummary(savedConceptMap); } } markCurrentCmapSaved(snapshot); showCmapStatus( 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 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; resetCmapPrototype(null, false); markCurrentCmapSaved(); renderConceptMapSelector(); showCmapStatus(tr("concept-map-not-found", "CMap not found")); } else if (state.currentConceptMap && state.conceptMaps.some((conceptMap) => conceptMap.slug === state.currentConceptMap.slug)) { await openStoredConceptMap(state.currentConceptMap.slug); } else if (state.conceptMaps.length) { await openStoredConceptMap(state.conceptMaps[0].slug); } else { state.currentConceptMap = 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); } 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 currentSlug = state.currentConceptMap && state.conceptMaps.some((conceptMap) => conceptMap.slug === state.currentConceptMap.slug) ? state.currentConceptMap.slug : null; navigateToHash(currentSlug ? cmapRoute(currentSlug) : "#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-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-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", () => { const prototype = cmapPrototypeState(); const record = prototype.editor ? prototype.editor.selected() : null; if (!record || record.kind !== "submap") return; const name = window.prompt(tr("submap-name", "Name of the new concept map"), record.childMap || record.label); if (!name || !name.trim()) return; prototype.editor.promoteSubmap(record, name.trim()); prototype.editor.openSubmapMap(record); $("cmap-promote-submap").disabled = true; }); $("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode()); $("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-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", () => { 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-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(); }); $("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-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 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(); if (selectedPage === null) { pageInput.setCustomValidity(tr("select-listed-page", "Select a wiki page from the list or clear the field.")); 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 linkedPage = selectedPage || null; const linkedCmapValue = selectedCmap || ""; const parentCmapLink = linkedCmapValue === "__parent__"; const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null; const changes = { kind: record && record.kind === "submap" ? "submap" : (linkedPage ? "page" : "concept"), label, synopsis: $("cmap-concept-synopsis").value, pageSlug: linkedPage, cmapSlug: linkedCmap, parentCmapLink, imageSource: cmapDialogImageSource, backgroundColor: $("cmap-concept-background").value, textColor: $("cmap-concept-text-color").value, fontFamily: $("cmap-concept-font-family").value || "Arial, Helvetica, sans-serif", fontSize: `${fontSize}pt` }; if (!record || record.kind !== "submap") { changes.borderColor = linkedPage ? "#4479a1" : ((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00"); } else { changes.submapBackgroundColor = $("cmap-submap-background").value; changes.submapBorderColor = $("cmap-submap-border").value; } 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-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(); }); $("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"); }); $("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-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-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, password: $("new-password").value, role: $("new-role").value, enabled: true }) }); event.target.reset(); await loadUsersAdmin(); }); 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); }); })();