import { newPageReference, pageReference, splitPageReference } from "../reference.js"; import { cmapRoute, pageRoute } from "../routes.js"; import { cmapMentionTarget, escapeHtml } from "../markdown.js"; import { buildBundle, preparedMapDocument, replaceAttachmentUrls, validateBundle } from "./interchange.js"; /** * Own the complete browser-side CMap workspace. * * The workspace coordinates the CMap editor, its dialogs, appearance, * persistence, history and browser event handling. The wiki application * supplies only the concrete services needed to cross the module boundary. */ export class CmapWorkspace { constructor( state, api, tr, can, show, renderBreadcrumbs, renderToc, pageDisplayDate, loadPages, openPage, navigateToHash, canonicalPageReference, renderMarkdown, renderPageCmapConnections, escapeMarkdownLinkLabel ) { 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")); let pendingCmapTransition = null; let cmapStatusTimer = null; let cmapAutosaveTimer = null; let cmapSavePromise = null; let cmapEmbedHydrationTimer = null; const cmapDescriptionPreviewCache = new Map(); let cmapDescriptionTooltip = null; let cmapDescriptionTooltipToken = 0; const CMAP_AUTOSAVE_DELAY = 1500; /** Queue rendering of CMap embeds after their sanitized HTML enters the DOM. */ function queueCmapEmbedHydration() { if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer); cmapEmbedHydrationTimer = window.setTimeout(() => { cmapEmbedHydrationTimer = null; hydrateCmapEmbeds(document).catch((error) => console.error(error)); }, 0); } /** Render all pending CMap embeds below root as read-only CMap editors. */ async function hydrateCmapEmbeds(root) { const embeds = Array.from(root.querySelectorAll( ".rw-cmap-embed:not([data-cmap-hydrated])")); for (const embed of embeds) { embed.dataset.cmapHydrated = "loading"; const conceptMap = cmapMentionTarget( embed.dataset.cmapReference || "", state.conceptMaps); if (!conceptMap) { embed.dataset.cmapHydrated = "error"; embed.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = tr("concept-map-not-found", "CMap not found"); embed.append(message); continue; } try { const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`); const documentValue = decodeStoredConceptMapDocument(stored); embed.replaceChildren(); embed.dataset.cmapSlug = conceptMap.slug; embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap."); const header = document.createElement("header"); const title = document.createElement("strong"); title.textContent = conceptMap.title; const hint = document.createElement("span"); hint.textContent = tr( "embedded-concept-map-help", "Double-click to open this CMap."); header.append(title, hint); const viewport = document.createElement("div"); viewport.className = "rw-cmap-embed-viewport"; const canvas = document.createElement("div"); canvas.className = "cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas"; viewport.append(canvas); embed.append(header, viewport); const editor = window.RacketWikiCmap.createEditor(canvas, { Cmap: window.Cmap, renderItem: (record) => cmapNodeHtml(record), onOpenExternalUrl: (record) => openCmapExternalUrl(record) }); editor.loadDocument(documentValue); const visible = editor.visibleItemRecords(); if (visible.length) { const left = Math.min(...visible.map((item) => Number(item.node.attr("x")))) - 24; const top = Math.min(...visible.map((item) => Number(item.node.attr("y")))) - 24; const right = Math.max(...visible.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24; const bottom = Math.max(...visible.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24; const availableWidth = Math.max(320, embed.clientWidth - 2); const scale = Math.min( 1, availableWidth / Math.max(1, right - left), 520 / Math.max(1, bottom - top)); editor.zoomFactor = scale; editor.map.zoom(scale); viewport.style.height = `${Math.max(180, Math.ceil((bottom - top) * scale))}px`; window.requestAnimationFrame(() => { viewport.scrollLeft = Math.max(0, left * scale); viewport.scrollTop = Math.max(0, top * scale); }); } embed.dataset.cmapHydrated = "ready"; embed.addEventListener("dblclick", () => { navigateToHash(cmapRoute(conceptMap.slug)) .catch((error) => console.error(error)); }); } catch (error) { embed.dataset.cmapHydrated = "error"; embed.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = error.message; embed.append(message); } } } const CMAP_START_SLUG_KEY = "racket-wiki:start-cmap"; function startCmapSlug() { try { return window.localStorage.getItem(CMAP_START_SLUG_KEY) || ""; } catch (_error) { return ""; } } function setStartCmapSlug(slug) { try { if (slug) window.localStorage.setItem(CMAP_START_SLUG_KEY, slug); else window.localStorage.removeItem(CMAP_START_SLUG_KEY); } catch (error) { console.warn("The start CMap preference could not be stored.", error); } } //////////////////////////////////////////////////////////////////////////////// // 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 aspects = (Array.isArray(record.aspects) ? record.aspects : []) .map((aspect) => `${escapeHtml(aspect)}`) .join(""); const aspectList = aspects ? `
${aspects}
` : ""; const personNames = (Array.isArray(record.tags) ? record.tags : []) .filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value) .map((tag) => String(tag.value)); const peopleLabel = personNames.length ? tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : ""; const personTags = personNames.length ? `
${personNames.map(escapeHtml).join(" · ")}
` : ""; const usageCount = record.conceptId && record.kind !== "phrase" ? effectiveCmapConceptUsage(record) : null; const usageLabel = usageCount === null ? "" : tr("concept-usage-count", "{count} placements across all concept maps") .replace("{count}", String(usageCount)); const usage = usageCount === null ? "" : `(${usageCount})`; const descriptionReference = canonicalPageReference(record.descriptionPageSlug || ""); const descriptionExists = Boolean(descriptionReference && state.pages.some((page) => page.slug === descriptionReference)); const descriptionButton = record.descriptionPageSlug ? `` : ""; const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink; const linkedButton = linkedTarget && record.kind !== "submap" ? `` : ""; const externalButton = record.externalUrl ? `` : ""; const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : ""; return `
${descriptionButton}${linkedButton}${externalButton}${image}
${safeLabel} ${usage}
${aspectList}${personTags}${safeSynopsis ? `
${safeSynopsis}
` : ""}
`; } function effectiveCmapConceptUsage(record) { const byMap = state.cmapConceptUsage.get(record.conceptId); const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0; const prototype = cmapPrototypeState(); const isActiveEditorRecord = Boolean( prototype.editor && prototype.editor.containsItemRecord(record)); if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1); const currentSlug = state.currentConceptMapSource?.slug || state.currentConceptMap?.slug || null; const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0; return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1)); } function ensureCmapDescriptionTooltip() { if (cmapDescriptionTooltip) return cmapDescriptionTooltip; cmapDescriptionTooltip = document.createElement("div"); cmapDescriptionTooltip.id = "cmap-description-tooltip"; cmapDescriptionTooltip.className = "cmap-description-tooltip hidden"; cmapDescriptionTooltip.setAttribute("role", "tooltip"); document.body.append(cmapDescriptionTooltip); return cmapDescriptionTooltip; } function hideCmapDescriptionTooltip() { cmapDescriptionTooltipToken += 1; if (cmapDescriptionTooltip) cmapDescriptionTooltip.classList.add("hidden"); } async function showCmapDescriptionTooltip(button) { if (!button.classList.contains("is-filled")) return; const itemElement = button.closest("[data-rw-cmap-item-id]"); const prototype = cmapPrototypeState(); const record = itemElement && prototype.editor ? prototype.editor.itemRecord(itemElement.dataset.rwCmapItemId) : null; if (!record || !record.descriptionPageSlug) return; const reference = canonicalPageReference(record.descriptionPageSlug); const tooltip = ensureCmapDescriptionTooltip(); const token = ++cmapDescriptionTooltipToken; button.setAttribute("aria-describedby", tooltip.id); tooltip.classList.remove("hidden"); tooltip.textContent = tr("loading", "Loading…"); const rect = button.getBoundingClientRect(); tooltip.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 440))}px`; tooltip.style.top = `${Math.min(window.innerHeight - 180, rect.bottom + 8)}px`; try { let preview = cmapDescriptionPreviewCache.get(reference); if (preview === undefined) { const page = await api(`/api/pages/${encodeURIComponent(reference)}`); preview = String(page.markdown || "").trim() ? renderMarkdown(page.markdown, page.slug) : null; cmapDescriptionPreviewCache.set(reference, preview); } if (token !== cmapDescriptionTooltipToken) return; if (!preview) { button.classList.remove("is-filled"); button.classList.add("is-empty"); tooltip.classList.add("hidden"); return; } tooltip.innerHTML = `
${preview}
`; const height = tooltip.offsetHeight; if (rect.bottom + 8 + height > window.innerHeight) { tooltip.style.top = `${Math.max(8, rect.top - height - 8)}px`; } } catch (error) { if (token !== cmapDescriptionTooltipToken) return; button.classList.remove("is-filled"); button.classList.add("is-empty"); tooltip.classList.add("hidden"); if (error.status !== 404) console.error(error); } } /** * goal : Derive a compact synopsis from the currently opened wiki page. * pre : state.currentPage may be #f/null when no page is open. * result : Plain text of at most about 150 characters. */ function currentPageSynopsis() { if (!state.currentPage) return ""; const container = document.createElement("div"); container.innerHTML = renderMarkdown(state.currentPage.content || ""); const text = (container.textContent || "").replace(/\s+/g, " ").trim(); return text.length > 150 ? `${text.slice(0, 147)}…` : text; } /** * goal : Add a concept-like item to the active CMap prototype. * pre : resetCmapPrototype has created the RacketWikiCmap editor. * post : The item is draggable, selectable, resizable and linkable. * result : The item record created by the interaction layer. */ function addCmapPrototypeNode(options = {}) { const prototype = cmapPrototypeState(); if (!prototype.editor) return null; const conceptId = options.conceptId || sharedCmapConceptId(options.label); return prototype.editor.addItem(conceptId ? { ...options, conceptId } : options); } function normalizeCmapConceptName(label) { return String(label || "").trim().toLocaleLowerCase(); } function sharedCmapConceptId(label) { return state.cmapConceptIdsByName.get(normalizeCmapConceptName(label)) || null; } let cmapDialogRecord = null; let cmapDialogCreateContext = null; let cmapContextCreateContext = {}; let cmapDialogImageSource = ""; let cmapDialogImageRead = Promise.resolve(); function cmapColorValue(value, fallback = "#f3f6f8") { return /^#[0-9a-f]{6}$/i.test(value || "") ? value : fallback; } function cmapFontSizeInPoints(value, baseSize = 11) { const size = Number.parseFloat(value); if (!Number.isFinite(size)) return 11; if (/px$/i.test(value || "")) return size * 0.75; if (/em$/i.test(value || "")) return size * baseSize; if (/%$/i.test(value || "")) return (size / 100) * baseSize; return size; } function displayCmapFontSize(value) { return String(Math.round(value * 2) / 2); } function normalizedCmapFontSize(value, fallback) { const size = Number(value); const usableSize = Number.isFinite(size) ? size : fallback; return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2)); } function normalizedCmapExternalUrl(value) { const text = String(value || "").trim(); if (!text) return ""; try { const url = new URL(text); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch (_error) { return null; } } function openCmapExternalUrl(record) { const url = normalizedCmapExternalUrl(record && record.externalUrl); if (!url) return false; window.open(url, "_blank", "noopener,noreferrer"); return true; } function selectCmapConceptTab(name) { for (const tab of $("cmap-concept-form").querySelectorAll("[data-cmap-concept-tab]")) { const selected = tab.dataset.cmapConceptTab === name; tab.setAttribute("aria-selected", selected ? "true" : "false"); tab.tabIndex = selected ? 0 : -1; const panel = $(tab.getAttribute("aria-controls")); if (panel) panel.classList.toggle("hidden", !selected); } $("cmap-concept-form").querySelector(".cmap-concept-dialog-body").scrollTop = 0; } function selectCmapFont(fontFamily, selectId = "cmap-concept-font-family") { const select = $(selectId); const value = fontFamily || "Arial, Helvetica, sans-serif"; const existing = Array.from(select.options).find((option) => option.value === value); if (!existing) { const option = document.createElement("option"); option.value = value; option.textContent = value; select.append(option); } select.value = value; } function titledCmapComboboxEntry(record) { const label = record.title || record.slug; return { value: record.slug, label, description: record.slug }; } function populateCmapPageOptions(record) { const pages = [...state.pages].sort((a, b) => a.title.localeCompare(b.title)); const entries = pages.map((page) => titledCmapComboboxEntry(page)); if (record.pageSlug && !pages.some((page) => page.slug === record.pageSlug)) { entries.push({ value: record.pageSlug, label: record.pageSlug }); } cmapPageCombobox.setOptions(entries, record.pageSlug || ""); $("cmap-concept-page-row").classList.toggle("hidden", record.kind === "submap"); } function populateCmapLinkOptions(record) { const prototype = cmapPrototypeState(); const entries = []; if (prototype.editor && prototype.editor.activeMapRoot) { const parentLabel = `↩ ${tr("parent-concept-map", "Parent concept map")}`; entries.push({ value: "__parent__", label: parentLabel }); } for (const conceptMap of [...state.conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) { entries.push(titledCmapComboboxEntry(conceptMap)); } if (record.cmapSlug && !state.conceptMaps.some((conceptMap) => conceptMap.slug === record.cmapSlug)) { entries.push({ value: record.cmapSlug, label: record.cmapSlug }); } const selectedValue = record.parentCmapLink ? "__parent__" : (record.cmapSlug || ""); cmapLinkCombobox.setOptions(entries, selectedValue); $("cmap-concept-cmap-row").classList.toggle("hidden", record.kind === "submap"); } function updateCmapImagePreview() { const row = $("cmap-concept-image-preview-row"); const preview = $("cmap-concept-image-preview"); if (!cmapDialogImageSource) { row.classList.add("hidden"); preview.removeAttribute("src"); return; } preview.src = cmapDialogImageSource; row.classList.remove("hidden"); } async function loadPeople() { const result = await api("/api/people"); state.people = Array.isArray(result.people) ? result.people : []; return state.people; } function selectedCmapPersonNames() { return Array.from($("cmap-person-tag-options").querySelectorAll("input[type='checkbox']:checked")) .map((input) => input.dataset.personName).filter(Boolean); } function renderCmapPersonTagOptions(selectedNames = []) { const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase())); const target = $("cmap-person-tag-options"); target.replaceChildren(); const visiblePeople = state.people.filter((person) => person.active || selected.has(String(person.name).toLocaleLowerCase())); for (const person of visiblePeople) { const label = document.createElement("label"); label.className = "cmap-person-tag-option"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.dataset.personName = person.name; checkbox.checked = selected.has(String(person.name).toLocaleLowerCase()); const text = document.createElement("span"); text.textContent = person.active ? person.name : `${person.name} (${tr("inactive", "inactive")})`; label.append(checkbox, text); target.append(label); } const knownNames = new Set(state.people.map((person) => String(person.name).toLocaleLowerCase())); for (const selectedName of selectedNames) { if (knownNames.has(String(selectedName).toLocaleLowerCase())) continue; const label = document.createElement("label"); label.className = "cmap-person-tag-option"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.dataset.personName = selectedName; checkbox.checked = true; const text = document.createElement("span"); text.textContent = `${selectedName} (${tr("inactive", "inactive")})`; label.append(checkbox, text); target.append(label); } if (!target.childElementCount) { const empty = document.createElement("span"); empty.className = "muted"; empty.textContent = tr("no-active-people", "No active people yet."); target.append(empty); } const summary = $("cmap-person-tags-picker").querySelector("summary"); const selectedCount = selected.size; summary.textContent = selectedCount ? tr("people-selected", "{count} people selected").replace("{count}", String(selectedCount)) : tr("select-people", "Select people"); } async function createPersonFromInput(inputId, selectInConceptDialog = false) { const input = $(inputId); const name = input.value.trim(); if (!name) { input.focus(); return null; } const selected = selectInConceptDialog ? selectedCmapPersonNames() : []; const person = await api("/api/people", { method: "POST", body: JSON.stringify({ name }) }); input.value = ""; await loadPeople(); if (selectInConceptDialog) renderCmapPersonTagOptions([...selected, person.name]); return person; } function renderPeopleManagement() { const target = $("cmap-people-list"); target.replaceChildren(); for (const person of state.people) { const row = document.createElement("div"); row.className = "cmap-person-admin-row"; const name = document.createElement("strong"); name.textContent = person.name; const activeLabel = document.createElement("label"); const active = document.createElement("input"); active.type = "checkbox"; active.checked = Boolean(person.active); const activeText = document.createElement("span"); activeText.textContent = tr("active", "Active"); activeLabel.append(active, activeText); const save = document.createElement("button"); save.type = "button"; save.textContent = tr("save", "Save"); save.addEventListener("click", async () => { save.disabled = true; try { await api(`/api/people/${person.id}`, { method: "PUT", body: JSON.stringify({ name: person.name, active: active.checked }) }); await loadPeople(); renderPeopleManagement(); } catch (error) { window.alert(error.message); save.disabled = false; } }); row.append(name, activeLabel, save); target.append(row); } } async function openPeopleManagement() { await loadPeople(); renderPeopleManagement(); $("cmap-people-dialog").showModal(); closeCmapContextMenu(); } function readCmapImage(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true }); reader.addEventListener("error", () => reject(reader.error || new Error("Image could not be read.")), { once: true }); reader.readAsDataURL(file); }); } /** * goal : Open the concept editor without changing its wiki-page identity. * pre : record is a non-phrase item in the current CMap editor. * post : A modal form shows concept text, presentation and image fields. */ function openCmapConceptDialog(record) { if (!record || record.kind === "phrase") return; cmapDialogRecord = record; cmapDialogCreateContext = null; $("cmap-concept-dialog-title").textContent = tr("edit-concept", "Edit concept"); cmapDialogImageSource = record.imageSource || ""; cmapDialogImageRead = Promise.resolve(); $("cmap-concept-label").value = record.label || ""; $("cmap-concept-synopsis").value = record.synopsis || ""; $("cmap-concept-aspects").value = (record.aspects || []).join(", "); const selectedPeople = (Array.isArray(record.tags) ? record.tags : []) .filter((tag) => tag && typeof tag === "object" && tag.type === "person") .map((tag) => tag.value); renderCmapPersonTagOptions(selectedPeople); $("cmap-person-tags-picker").open = false; $("cmap-concept-description-page").value = record.descriptionPageSlug || ""; $("cmap-concept-description-link").href = record.descriptionPageSlug ? pageRoute(record.descriptionPageSlug) : "#"; $("cmap-concept-description-link").classList.toggle("hidden", !record.descriptionPageSlug); $("cmap-concept-external-url").value = record.externalUrl || ""; $("cmap-concept-external-url").setCustomValidity(""); $("cmap-concept-background").value = cmapColorValue(record.backgroundColor); $("cmap-concept-background-label").textContent = record.kind === "submap" ? tr("main-concept-background-color", "Main concept background color") : tr("background-color", "Background color"); $("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap"); $("cmap-submap-background").value = cmapColorValue( record.submapBackgroundColor, "#edf7e8"); $("cmap-submap-border").value = cmapColorValue( record.submapBorderColor, "#57834a"); $("cmap-concept-text-color").value = cmapColorValue(record.textColor, "#222222"); selectCmapFont(record.fontFamily); const titleFontSize = cmapFontSizeInPoints(record.fontSize); $("cmap-concept-font-size").value = displayCmapFontSize(titleFontSize); $("cmap-concept-bold").checked = String(record.fontWeight || "700") !== "400"; $("cmap-concept-italic").checked = record.fontStyle === "italic"; $("cmap-concept-synopsis-text-color").value = cmapColorValue( record.synopsisTextColor || record.textColor, "#222222"); selectCmapFont( record.synopsisFontFamily || record.fontFamily, "cmap-concept-synopsis-font-family"); $("cmap-concept-synopsis-font-size").value = displayCmapFontSize( cmapFontSizeInPoints(record.synopsisFontSize || "0.84em", titleFontSize)); $("cmap-concept-synopsis-bold").checked = String( record.synopsisFontWeight || record.fontWeight || "700") !== "400"; $("cmap-concept-synopsis-italic").checked = (record.synopsisFontStyle || record.fontStyle) === "italic"; $("cmap-concept-image").value = ""; populateCmapPageOptions(record); populateCmapLinkOptions(record); updateCmapImagePreview(); for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) { updateCmapColorControl(input); } renderCmapStyleOptions(); selectCmapConceptTab("content"); $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); } function openNewCmapConceptDialog(context = {}) { cmapDialogRecord = null; cmapDialogCreateContext = context; cmapDialogImageSource = ""; cmapDialogImageRead = Promise.resolve(); $("cmap-concept-dialog-title").textContent = tr("add-concept", "Add concept"); $("cmap-concept-label").value = "New concept"; $("cmap-concept-synopsis").value = ""; $("cmap-concept-aspects").value = ""; renderCmapPersonTagOptions([]); $("cmap-person-tags-picker").open = false; $("cmap-concept-description-page").value = ""; $("cmap-concept-description-link").classList.add("hidden"); $("cmap-concept-external-url").value = ""; $("cmap-concept-external-url").setCustomValidity(""); $("cmap-concept-background").value = "#fff4cf"; $("cmap-concept-background-label").textContent = tr("background-color", "Background color"); $("cmap-submap-style-fields").classList.add("hidden"); $("cmap-submap-background").value = "#edf7e8"; $("cmap-submap-border").value = "#57834a"; $("cmap-concept-text-color").value = "#222222"; selectCmapFont("Arial, Helvetica, sans-serif"); $("cmap-concept-font-size").value = "11"; $("cmap-concept-bold").checked = true; $("cmap-concept-italic").checked = false; $("cmap-concept-synopsis-text-color").value = "#4d4d4d"; selectCmapFont("Arial, Helvetica, sans-serif", "cmap-concept-synopsis-font-family"); $("cmap-concept-synopsis-font-size").value = "9"; $("cmap-concept-synopsis-bold").checked = false; $("cmap-concept-synopsis-italic").checked = false; $("cmap-concept-image").value = ""; populateCmapPageOptions({ kind: "concept", pageSlug: null }); populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false }); updateCmapImagePreview(); for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) { updateCmapColorControl(input); } renderCmapStyleOptions(); selectCmapConceptTab("content"); $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); } const cmapStyleValueKeys = [ "backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle", "synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight", "synopsisFontStyle", "submapBackgroundColor", "submapBorderColor" ]; function seededCmapStyle(id, nameKey, options) { const synopsisSize = Math.max(6, options.size - 2); return { id, nameKey, protected: id === "default", values: { backgroundColor: options.background, textColor: options.text, fontFamily: options.font, fontSize: options.size, fontWeight: options.bold ? "700" : "400", fontStyle: options.italic ? "italic" : "normal", synopsisTextColor: options.synopsisText || options.text, synopsisFontFamily: options.font, synopsisFontSize: synopsisSize, synopsisFontWeight: "400", synopsisFontStyle: options.italic ? "italic" : "normal", submapBackgroundColor: "#edf7e8", submapBorderColor: "#57834a" } }; } function defaultCmapStyles() { return [ seededCmapStyle("default", "style-default", { background: "#fff4cf", text: "#222222", synopsisText: "#4d4d4d", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: false }), seededCmapStyle("subtle", "style-subtle", { background: "#f1f3f5", text: "#56616b", font: "system-ui, sans-serif", size: 10, bold: false, italic: false }), seededCmapStyle("emphasis", "style-emphasis", { background: "#e7f2fb", text: "#173b57", font: "Georgia, Times New Roman, serif", size: 12, bold: true, italic: false }), seededCmapStyle("warning", "style-warning", { background: "#fff0d5", text: "#713b00", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: true }), seededCmapStyle("success", "style-success", { background: "#e6f4e2", text: "#285b27", font: "Arial, Helvetica, sans-serif", size: 11, bold: false, italic: true }) ]; } function normalizedCmapStyleValues(values, fallbackValues) { if (!values || typeof values !== "object") return null; const fallback = fallbackValues || defaultCmapStyles()[0].values; const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize); return { backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor).toLowerCase(), textColor: cmapColorValue(values.textColor, fallback.textColor).toLowerCase(), fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ? values.fontFamily.trim() : fallback.fontFamily, fontSize, fontWeight: String(values.fontWeight) === "400" ? "400" : "700", fontStyle: values.fontStyle === "italic" ? "italic" : "normal", synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor).toLowerCase(), synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ? values.synopsisFontFamily.trim() : fallback.synopsisFontFamily, synopsisFontSize: normalizedCmapFontSize(values.synopsisFontSize, Math.max(6, fontSize - 2)), synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400", synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal", submapBackgroundColor: cmapColorValue(values.submapBackgroundColor, fallback.submapBackgroundColor).toLowerCase(), submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor).toLowerCase() }; } function normalizeCmapStyles(styles) { const seeds = defaultCmapStyles(); if (!Array.isArray(styles)) return seeds; const seenIds = new Set(["default"]); const normalized = styles.flatMap((style) => { const name = typeof style?.name === "string" ? style.name.trim() : ""; const nameKey = typeof style?.nameKey === "string" ? style.nameKey.trim() : ""; if (!style || typeof style.id !== "string" || !style.id || style.id === "default" || seenIds.has(style.id) || (!name && !nameKey)) return []; const values = normalizedCmapStyleValues(style.values, seeds[0].values); if (!values) return []; seenIds.add(style.id); return [{ id: style.id, ...(nameKey ? { nameKey } : { name }), protected: false, values }]; }); const storedDefault = styles.find((style) => style?.id === "default"); const defaultValues = normalizedCmapStyleValues(storedDefault?.values, seeds[0].values); return [{ ...seeds[0], values: defaultValues || seeds[0].values }, ...normalized]; } async function loadCmapStyles() { try { const result = await api("/api/cmap-styles"); cmapStyles = normalizeCmapStyles(result.styles); const databaseSnapshot = JSON.stringify(cmapStyles); const initialSnapshot = JSON.stringify(defaultCmapStyles()); let legacyStyles = null; try { const legacy = JSON.parse(window.localStorage.getItem("racket-wiki:cmap-styles:v1") || "null"); if (legacy?.version === 1 && Array.isArray(legacy.styles)) { legacyStyles = normalizeCmapStyles(legacy.styles); } } catch (error) { console.warn("Legacy browser-local CMap styles could not be read.", error); } if (legacyStyles && can("editor") && databaseSnapshot === initialSnapshot && JSON.stringify(legacyStyles) !== initialSnapshot) { cmapStyles = legacyStyles; if (await storeCmapStyles()) window.localStorage.removeItem("racket-wiki:cmap-styles:v1"); } else if (legacyStyles && (databaseSnapshot !== initialSnapshot || JSON.stringify(legacyStyles) === initialSnapshot)) { window.localStorage.removeItem("racket-wiki:cmap-styles:v1"); } renderCmapStyleOptions(); } catch (error) { console.warn("The CMap styles could not be read from the database.", error); cmapStyles = defaultCmapStyles(); } return cmapStyles; } async function storeCmapStyles() { try { const result = await api("/api/cmap-styles", { method: "PUT", body: JSON.stringify({ styles: cmapStyles }) }); cmapStyles = normalizeCmapStyles(result.styles); return true; } catch (error) { console.warn("The CMap styles could not be stored in the database.", error); window.alert(tr("style-storage-failed", "The style could not be stored in the wiki database.")); return false; } } let cmapStyles = defaultCmapStyles(); const defaultCmapColorPalette = ["#ffffff", "#f1f3f5", "#e7f2fb", "#e6f4e2", "#fff4cf", "#fff0d5", "#f7dede", "#dcd8f7", "#222222", "#4479a1", "#57834a", "#a97c00"]; const cmapColorPaletteStorageKey = "racket-wiki:cmap-color-palette:v1"; function loadCmapColorPalette() { try { const stored = JSON.parse(window.localStorage.getItem(cmapColorPaletteStorageKey) || "null"); if (Array.isArray(stored) && stored.length === defaultCmapColorPalette.length) { return stored.map((color, index) => cmapColorValue(color, defaultCmapColorPalette[index])); } } catch (error) { console.warn("The CMap color palette could not be read.", error); } return [...defaultCmapColorPalette]; } function storeCmapColorPalette(colors) { try { window.localStorage.setItem(cmapColorPaletteStorageKey, JSON.stringify(colors)); } catch (error) { console.warn("The CMap color palette could not be stored.", error); } } function openNativeCmapColorPicker(initialColor, onInput) { const picker = document.createElement("input"); picker.type = "color"; picker.className = "cmap-native-color-picker"; picker.value = cmapColorValue(initialColor, "#ffffff"); document.body.append(picker); let removed = false; const cleanup = () => { if (removed) return; removed = true; picker.remove(); }; picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff"))); picker.addEventListener("change", () => window.setTimeout(cleanup, 0), { once: true }); picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true }); try { if (typeof picker.showPicker === "function") picker.showPicker(); else picker.click(); } catch (_error) { picker.click(); } } function updateCmapColorControl(input) { const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch"); if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff"); } function installCmapColorPickers() { const colors = loadCmapColorPalette(); for (const control of document.querySelectorAll(".cmap-color-control")) { const input = control.querySelector(".cmap-color-input"); const swatch = control.querySelector(".cmap-color-swatch"); swatch.title = tr("color-swatch-help", "Click for the palette; double-click for a custom color"); const palette = document.createElement("span"); palette.className = "cmap-color-palette hidden"; colors.forEach((color, index) => { const choice = document.createElement("button"); choice.type = "button"; choice.dataset.cmapColorIndex = String(index); choice.style.backgroundColor = color; choice.title = `${color} — ${tr("change-palette-color", "double-click to change")}`; choice.setAttribute("aria-label", color); let clickTimer = null; choice.addEventListener("click", () => { if (clickTimer !== null) window.clearTimeout(clickTimer); clickTimer = window.setTimeout(() => { clickTimer = null; input.value = colors[index]; input.dispatchEvent(new Event("input", { bubbles: true })); palette.classList.add("hidden"); }, 240); }); choice.addEventListener("dblclick", (event) => { event.preventDefault(); if (clickTimer !== null) window.clearTimeout(clickTimer); clickTimer = null; openNativeCmapColorPicker(colors[index], (newColor) => { colors[index] = newColor; for (const matchingChoice of document.querySelectorAll( `.cmap-color-palette button[data-cmap-color-index="${index}"]`)) { matchingChoice.style.backgroundColor = newColor; matchingChoice.title = `${newColor} — ${tr("change-palette-color", "double-click to change")}`; matchingChoice.setAttribute("aria-label", newColor); } storeCmapColorPalette(colors); }); }); palette.append(choice); }); control.append(palette); swatch.addEventListener("click", () => { for (const other of document.querySelectorAll(".cmap-color-palette")) { if (other !== palette) other.classList.add("hidden"); } palette.classList.toggle("hidden"); }); swatch.addEventListener("dblclick", (event) => { event.preventDefault(); palette.classList.add("hidden"); openNativeCmapColorPicker(input.value, (newColor) => { input.value = newColor; input.dispatchEvent(new Event("input", { bubbles: true })); }); }); input.addEventListener("input", () => updateCmapColorControl(input)); updateCmapColorControl(input); } } function captureCmapAppearance() { return normalizedCmapStyleValues({ backgroundColor: $("cmap-concept-background").value, textColor: $("cmap-concept-text-color").value, fontFamily: $("cmap-concept-font-family").value, fontSize: $("cmap-concept-font-size").value, fontWeight: $("cmap-concept-bold").checked ? "700" : "400", fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal", synopsisTextColor: $("cmap-concept-synopsis-text-color").value, synopsisFontFamily: $("cmap-concept-synopsis-font-family").value, synopsisFontSize: $("cmap-concept-synopsis-font-size").value, synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400", synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal", submapBackgroundColor: $("cmap-submap-background").value, submapBorderColor: $("cmap-submap-border").value }); } function applyCmapAppearance(values) { const style = normalizedCmapStyleValues(values); if (!style) return; $("cmap-concept-background").value = style.backgroundColor; $("cmap-concept-text-color").value = style.textColor; selectCmapFont(style.fontFamily); $("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize); $("cmap-concept-bold").checked = style.fontWeight === "700"; $("cmap-concept-italic").checked = style.fontStyle === "italic"; $("cmap-concept-synopsis-text-color").value = style.synopsisTextColor; selectCmapFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family"); $("cmap-concept-synopsis-font-size").value = displayCmapFontSize(style.synopsisFontSize); $("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700"; $("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic"; $("cmap-submap-background").value = style.submapBackgroundColor; $("cmap-submap-border").value = style.submapBorderColor; for (const input of $("cmap-concept-panel-appearance").querySelectorAll(".cmap-color-input")) { updateCmapColorControl(input); } } function sameCmapAppearance(left, right) { return Boolean(left && right) && cmapStyleValueKeys.every((key) => left[key] === right[key]); } function cmapStyleName(style) { return style.nameKey ? tr(style.nameKey, style.nameKey) : style.name; } function matchingCmapStyleId() { const current = captureCmapAppearance(); return cmapStyles.find((style) => sameCmapAppearance(style.values, current))?.id || ""; } function updateCmapStyleDeleteButton() { const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value); $("cmap-delete-style").disabled = !selected || selected.protected; } function renderCmapStyleOptions(selectedId = matchingCmapStyleId()) { const usableId = cmapStyles.some((style) => style.id === selectedId) ? selectedId : ""; for (const select of [$("cmap-concept-quick-style"), $("cmap-concept-style-preset")]) { select.replaceChildren(); const custom = document.createElement("option"); custom.value = ""; custom.textContent = tr("custom-style", "Custom"); select.append(custom); for (const style of cmapStyles) { const option = document.createElement("option"); option.value = style.id; option.textContent = cmapStyleName(style); select.append(option); } select.value = usableId; } updateCmapStyleDeleteButton(); } function syncCmapStyleSelection() { const matchingId = matchingCmapStyleId(); $("cmap-concept-quick-style").value = matchingId; $("cmap-concept-style-preset").value = matchingId; updateCmapStyleDeleteButton(); } function applySelectedCmapStyle(event) { const selectedId = event?.currentTarget?.value ?? $("cmap-concept-style-preset").value; $("cmap-concept-quick-style").value = selectedId; $("cmap-concept-style-preset").value = selectedId; const style = cmapStyles.find((candidate) => candidate.id === selectedId); if (!style) { updateCmapStyleDeleteButton(); return; } applyCmapAppearance(style.values); updateCmapStyleDeleteButton(); } function newCmapStyleId() { try { if (window.crypto && typeof window.crypto.randomUUID === "function") { return `custom-${window.crypto.randomUUID()}`; } } catch (_error) { // Fall back to a timestamp when randomUUID is unavailable in this context. } return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`; } async function saveCurrentCmapStyle() { const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value); const proposedName = selected && !selected.nameKey ? selected.name : ""; const name = window.prompt(tr("style-name-prompt", "Name for this style"), proposedName); if (name === null) return; const cleanName = name.trim(); if (!cleanName) { window.alert(tr("style-name-required", "Enter a style name.")); return; } const existing = cmapStyles.find((style) => cmapStyleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase()); if (existing?.protected) { window.alert(tr("default-style-protected", "The default style cannot be changed or deleted.")); return; } if (existing && !window.confirm(tr( "replace-style-confirm", 'Replace the existing style "{name}"?').replace("{name}", cmapStyleName(existing)))) return; const replacement = { id: existing?.id || newCmapStyleId(), name: cleanName, protected: false, values: captureCmapAppearance() }; const previousStyles = cmapStyles; cmapStyles = existing ? cmapStyles.map((style) => style.id === existing.id ? replacement : style) : [...cmapStyles, replacement]; if (!await storeCmapStyles()) { cmapStyles = previousStyles; return; } renderCmapStyleOptions(replacement.id); } async function deleteSelectedCmapStyle() { const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value); if (!selected) return; if (selected.protected) { window.alert(tr("default-style-protected", "The default style cannot be changed or deleted.")); return; } if (!window.confirm(tr( "delete-style-confirm", 'Delete style "{name}"?').replace("{name}", cmapStyleName(selected)))) return; const previousStyles = cmapStyles; cmapStyles = cmapStyles.filter((style) => style.id !== selected.id); if (!await storeCmapStyles()) { cmapStyles = previousStyles; return; } renderCmapStyleOptions(); } function setCmapZoom(value) { const prototype = cmapPrototypeState(); const percent = Math.max(25, Math.min(300, Number(value) || 100)); $("cmap-zoom-percent").value = String(percent); try { window.localStorage.setItem(cmapZoomStorageKey(), String(percent)); } catch (error) { console.warn("The CMap zoom factor could not be stored.", error); } const canvas = $("cmap-canvas"); canvas.style.setProperty("--cmap-a4-width", `${1123 * percent / 100}px`); canvas.style.setProperty("--cmap-a4-height", `${794 * percent / 100}px`); return prototype.editor ? prototype.editor.setZoom(percent) : percent; } function setCmapPageGuides(visible) { $("cmap-canvas").classList.toggle("cmap-page-guides-hidden", !visible); $("cmap-toggle-page-guides").setAttribute("aria-checked", String(visible)); try { window.localStorage.setItem("racket-wiki:cmap-a4-page-guides", visible ? "true" : "false"); } catch (error) { console.warn("The CMap page-boundary preference could not be stored.", error); } } function restoreCmapPageGuides() { let visible = true; try { visible = window.localStorage.getItem("racket-wiki:cmap-a4-page-guides") !== "false"; } catch (error) { console.warn("The CMap page-boundary preference could not be read.", error); } setCmapPageGuides(visible); } function showCmapStatus(message, temporary = false) { const status = $("cmap-save-status"); window.clearTimeout(cmapStatusTimer); status.textContent = message || ""; status.classList.toggle("cmap-status-visible", Boolean(message)); if (message && temporary) { cmapStatusTimer = window.setTimeout(() => { status.classList.remove("cmap-status-visible"); status.textContent = ""; }, 1800); } } function renderHiddenCmapItems() { const details = $("cmap-hidden-items"); const options = $("cmap-hidden-item-options"); const prototype = cmapPrototypeState(); const hidden = prototype.editor ? prototype.editor.hiddenItemsInCurrentContext() : []; details.classList.toggle("hidden", hidden.length === 0); details.querySelector("summary").textContent = hidden.length ? `${tr("hidden-concepts", "Hidden concepts")} (${hidden.length})` : tr("hidden-concepts", "Hidden concepts"); options.replaceChildren(); for (const record of hidden) { const button = document.createElement("button"); button.type = "button"; button.textContent = record.label; button.title = tr("show-hidden-concept", "Show this concept again"); button.addEventListener("click", () => { prototype.editor.showItemInCurrentContext(record); details.open = false; }); options.append(button); } } function cmapZoomStorageKey() { const storedMap = state.currentConceptMap ? state.currentConceptMap.slug : "unsaved"; const prototype = cmapPrototypeState(); const activeMap = prototype.editor && prototype.editor.activeMapRoot && prototype.editor.activeMapRoot.mapReference ? prototype.editor.activeMapRoot.mapReference.id : "root"; return `racket-wiki:cmap-zoom:${storedMap}:${activeMap}`; } function restoreCmapZoom() { let stored = 100; try { stored = Number(window.localStorage.getItem(cmapZoomStorageKey())); } catch (error) { console.warn("The stored CMap zoom factor could not be read.", error); } return setCmapZoom(stored >= 25 && stored <= 300 ? stored : 100); } function closeCmapContextMenu() { $("cmap-context-menu").classList.add("hidden"); $("cmap-tools-menu").setAttribute("aria-expanded", "false"); } function updateCmapSelectionToolbar(editor, selectedRecords = [], connector = null) { const toolbar = $("cmap-selection-toolbar"); const selected = Array.isArray(selectedRecords) ? selectedRecords : []; toolbar.dataset.selectionCount = String(selected.length); for (const button of toolbar.querySelectorAll("[data-cmap-layout]")) { button.disabled = !editor || !editor.canLayoutSelection(button.dataset.cmapLayout); } const actionButtons = new Map(Array.from( toolbar.querySelectorAll("[data-cmap-selection-action]"), (button) => [button.dataset.cmapSelectionAction, button] )); actionButtons.get("edit").disabled = !editor || selected.length !== 1 || Boolean(connector); actionButtons.get("group").disabled = !editor || !editor.canGroupSelection(); actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection(); actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext(); } function openCmapContextMenu(clientX, clientY, createContext = {}) { const menu = $("cmap-context-menu"); cmapContextCreateContext = createContext; // The CMap library assigns ever-increasing z-indices to map components. // Keep this fixed-position menu outside that stacking context. if (menu.parentElement !== document.body) document.body.append(menu); menu.classList.remove("hidden"); const left = Math.max(8, Math.min(clientX, window.innerWidth - menu.offsetWidth - 8)); const top = Math.max(8, Math.min(clientY, window.innerHeight - menu.offsetHeight - 8)); menu.style.left = `${left}px`; menu.style.top = `${top}px`; $("cmap-tools-menu").setAttribute("aria-expanded", "true"); } function cmapPlacementOptions() { const context = cmapContextCreateContext || {}; const options = {}; if (context.point) { options.x = Math.max(0, context.point.x - 70); options.y = Math.max(0, context.point.y - 30); } if (context.parentSubmap) { options.parentSubmap = context.parentSubmap; options.submapDepth = context.parentSubmap.submapDepth + 1; } return options; } /** * goal : Edit the selected concept presentation and synopsis. * pre : One concept, page, submap or linking phrase is selected. * post : A concept dialog or the inline phrase editor is opened. */ function editSelectedCmapNode(record = null) { const prototype = cmapPrototypeState(); const selectedRecord = record || (prototype.editor ? prototype.editor.selected() : null); if (!selectedRecord) { window.alert(tr("select-one-concept", "Select one concept first.")); return; } if (selectedRecord.kind === "phrase") { prototype.editor.editPhraseInline(selectedRecord); return; } openCmapConceptDialog(selectedRecord); } function cmapKeyboardEditingTarget(target) { return target instanceof Element && Boolean( target.closest("input, textarea, select, [contenteditable='true']")); } function handleCmapKeyboardShortcut(event) { if ($("cmap-view").classList.contains("hidden") || $("cmap-concept-dialog").open) return; if (cmapKeyboardEditingTarget(event.target)) return; const prototype = cmapPrototypeState(); const editor = prototype.editor; if (!editor) return; const commandKey = event.ctrlKey || event.metaKey; const key = event.key.toLocaleLowerCase(); if (commandKey && !event.altKey && key === "a") { event.preventDefault(); editor.selectAll(); return; } if (commandKey && !event.altKey && key === "c") { if (editor.copySelectionReferences()) event.preventDefault(); return; } if (commandKey && !event.altKey && key === "x") { if (editor.cutSelectionReferences()) event.preventDefault(); return; } if (commandKey && !event.altKey && key === "v") { if (editor.canPasteConceptReferences()) { event.preventDefault(); editor.pasteConceptReferences(); } return; } if (commandKey && !event.altKey && key === "z") { event.preventDefault(); if (event.shiftKey) { editor.redo(); } else { editor.undo(); } return; } if (commandKey && !event.altKey && !event.shiftKey && key === "y") { event.preventDefault(); editor.redo(); return; } if (commandKey && !event.altKey && key === "g") { event.preventDefault(); if (event.shiftKey) { editor.ungroupSelection(); } else { groupSelectedCmapItems(); } return; } if (!commandKey && !event.altKey && !event.shiftKey && (event.key === "Delete" || event.key === "Backspace")) { event.preventDefault(); editor.deleteSelection(); return; } if (!commandKey && !event.altKey && !event.shiftKey && event.key === "Escape") { editor.clearSelection(); return; } if (event.key === "F2" && !event.repeat && !commandKey && !event.altKey && editor.selectedAll().length === 1) { event.preventDefault(); editor.editSelected(); } } /** * goal : Add a labelled relation between two sample items. * pre : source and target are records from the active prototype. * post : source -> linking phrase -> target is visible. * result : The new linking-phrase record. */ function connectCmapPrototypeNodes(source, target, label) { const prototype = cmapPrototypeState(); if (!prototype.editor) return null; return prototype.editor.connectWithPhrase(source, target, label || "?????", false); } function groupSelectedCmapItems() { const prototype = cmapPrototypeState(); if (!prototype.editor || !prototype.editor.canGroupSelection()) return false; const label = window.prompt( tr("group-submap-name", "Name of the main concept for the new sub-CMap"), tr("sub-concept-map", "Sub concept map")); if (!label || !label.trim()) return false; return prototype.editor.groupSelection({ label: label.trim(), conceptId: sharedCmapConceptId(label), 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; updateCmapSelectionToolbar(null, [], null); console.info("[racket-wiki:cmap-host 0.2.122] 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)); } }, onOpenExternalUrl: (record) => openCmapExternalUrl(record), onOpenStoredSubMap: (record) => { if (record.cmapSlug) { navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error)); } }, onOpenBoundaryReference: () => { const source = state.currentConceptMapSource; if (source) { navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error)); } }, onOpenSubMap: (record) => { console.info("[racket-wiki:cmap-host 0.2.122] 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] : []); const canExtractSubmap = selected.length === 1 && record && record.kind === "submap"; $("cmap-promote-submap").disabled = !canExtractSubmap; $("cmap-promote-submap").title = ""; $("cmap-extract-selected").disabled = !canExtractSubmap; $("cmap-extract-selected").classList.toggle("hidden", !canExtractSubmap); $("cmap-edit-selected").disabled = selected.length !== 1; $("cmap-group-selected").disabled = !prototype.editor.canGroupSelection(); $("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection(); $("cmap-hide-selected").disabled = !prototype.editor.canHideSelectionInCurrentContext(); $("cmap-delete-selected").disabled = selected.length === 0 && !connector; $("cmap-cut-selected").disabled = !prototype.editor.canCutSelectionReferences(); $("cmap-copy-selected").disabled = !selected.some((item) => item.conceptId && item.kind !== "phrase"); $("cmap-paste-concepts").disabled = !prototype.editor.canPasteConceptReferences(); updateCmapSelectionToolbar(prototype.editor, selected, connector); }, onAutomaticLayoutChange: ({ beforeSnapshot, afterSnapshot }) => { // Asynchronous text/image measurement is renderer normalization, not // an edit. Advance the baseline only when it still equals the exact // pre-layout state, so a real intervening user change is never hidden. if (state.cmapSavedSnapshot === beforeSnapshot) { state.cmapSavedSnapshot = afterSnapshot; } }, onHistoryChange: ({ canUndo, canRedo }) => { $("cmap-undo").disabled = !canUndo; $("cmap-redo").disabled = !canRedo; scheduleCmapAutosave(); }, onMapChange: (mapReference) => { $("cmap-map-navigation").classList.toggle("hidden", !mapReference); $("cmap-active-map-title").textContent = mapReference ? mapReference.title : ""; restoreCmapZoom(); renderHiddenCmapItems(); }, onVisibilityChange: () => renderHiddenCmapItems(), onEditItem: (record) => editSelectedCmapNode(record), onCreateConnectedItem: (context) => openNewCmapConceptDialog(context), createRelationLabel: tr("create-relation", "Create relation"), editConceptLabel: tr("edit-concept", "Edit concept"), resizeConceptLabel: tr("resize-concept", "Resize concept"), relationLabel: tr("relation", "Relation") }); restoreCmapZoom(); console.info("[racket-wiki:cmap-host 0.2.122] editor stored", { editorAvailable: Boolean(prototype.editor), canvasChildCount: canvas.children.length }); if (cmapDocument) { prototype.editor.loadDocument(cmapDocument); renderHiddenCmapItems(); return; } if (!includeSample) { prototype.editor.resetHistory(); return; } const page = state.currentPage || state.pages[0] || null; const pageNode = addCmapPrototypeNode({ label: page ? page.title : state.siteTitle, synopsis: page ? currentPageSynopsis() : "Wiki page concept", kind: "page", pageSlug: page ? page.slug : null, x: 365, y: 220, width: 270, height: 125, backgroundColor: "#e7f2fb", borderColor: "#4479a1" }); const conceptNode = addCmapPrototypeNode({ label: tr("context", "Context"), synopsis: "A free concept without a wiki page.", x: 70, y: 120, backgroundColor: "#fff4cf", borderColor: "#a97c00" }); const subMapNode = addCmapPrototypeNode({ label: tr("sub-concept-map", "Sub concept map"), synopsis: "Placeholder for an expandable child map.", kind: "submap", childMap: "prototype-child", x: 690, y: 360, backgroundColor: "#edf7e8", borderColor: "#57834a" }); connectCmapPrototypeNodes(conceptNode, pageNode, "describes"); connectCmapPrototypeNodes(pageNode, subMapNode, "contains"); prototype.editor.clearSelection(); prototype.editor.resetHistory(); } /** * goal : Open the persistent CMap workspace without changing wiki pages. * pre : User can read the wiki frontend. * post : The selected stored CMap or the unsaved starter map is displayed. */ function renderConceptMapSelector() { const input = $("cmap-map-select"); input.placeholder = state.conceptMaps.length ? tr("select-concept-map", "Select a CMap") : tr("no-concept-maps", "No saved CMaps"); cmapMapCombobox.setOptions( state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)), state.currentConceptMap ? state.currentConceptMap.slug : ""); const hasStoredMap = Boolean(state.currentConceptMap); const referenceButton = $("cmap-wiki-reference"); referenceButton.classList.toggle("hidden", !hasStoredMap); if (hasStoredMap) { referenceButton.textContent = `${state.currentConceptMap.title} · cmap:${state.currentConceptMap.slug}`; referenceButton.dataset.markdown = `[${escapeMarkdownLinkLabel(state.currentConceptMap.title)}](cmap:${state.currentConceptMap.slug})`; } else { referenceButton.textContent = ""; delete referenceButton.dataset.markdown; } $("cmap-rename-map").disabled = !hasStoredMap; $("cmap-edit-metadata").disabled = !hasStoredMap; $("cmap-export-markdown").disabled = !hasStoredMap; $("cmap-set-start-map").disabled = !hasStoredMap; $("cmap-set-start-map").textContent = hasStoredMap && startCmapSlug() === state.currentConceptMap.slug ? tr("start-concept-map", "Start CMap") : tr("set-start-concept-map", "Use as start CMap"); $("cmap-delete-map").disabled = !hasStoredMap; $("cmap-create-snapshot").disabled = !hasStoredMap; $("cmap-history").disabled = !hasStoredMap; } async function loadConceptMaps() { const [result, usageResult] = await Promise.all([ api("/api/cmaps"), api("/api/cmaps/concept-usage").catch((error) => { console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error); return { placements: [] }; }) ]); state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : []; state.cmapConceptUsage = new Map(); state.cmapConceptIdsByName = new Map(); state.cmapPageConcepts = new Map(); const placements = Array.isArray(usageResult.placements) ? usageResult.placements : []; const pageByConcept = new Map(); for (const placement of placements) { if (!placement?.conceptId || !placement.pageSlug) continue; pageByConcept.set( placement.conceptId, canonicalPageReference(placement.pageSlug).toLocaleLowerCase()); } for (const placement of placements) { if (!placement || !placement.conceptId || !placement.cmapSlug) continue; const nameKey = normalizeCmapConceptName(placement.label); if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId); if (!state.cmapConceptUsage.has(placement.conceptId)) { state.cmapConceptUsage.set(placement.conceptId, new Map()); } state.cmapConceptUsage.get(placement.conceptId) .set(placement.cmapSlug, Number(placement.count) || 0); const pageKey = pageByConcept.get(placement.conceptId); if (!pageKey) continue; if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map()); const pageConcepts = state.cmapPageConcepts.get(pageKey); if (!pageConcepts.has(placement.conceptId)) { pageConcepts.set(placement.conceptId, { conceptId: placement.conceptId, label: placement.label || tr("concept", "Concept"), count: 0, maps: new Map() }); } const concept = pageConcepts.get(placement.conceptId); const count = Number(placement.count) || 0; concept.count += count; const existingMap = concept.maps.get(placement.cmapSlug); concept.maps.set(placement.cmapSlug, { slug: placement.cmapSlug, title: placement.cmapTitle || placement.cmapSlug, count: count + (existingMap?.count || 0) }); } renderConceptMapSelector(); const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.refreshConceptUsageIndicators(); if (state.currentPage) renderPageCmapConnections(state.currentPage); return state.conceptMaps; } function normalizeCmapConceptIdentities(documentValue, cmapSlug) { const legacyIds = new Map(); const normalize = (conceptId) => { const prefixedUuid = typeof conceptId === "string" && conceptId.match( /^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); if (prefixedUuid) return prefixedUuid[1].toLowerCase(); if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId; if (!legacyIds.has(conceptId)) legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`); return legacyIds.get(conceptId); }; for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) { concept.id = normalize(concept.id); } for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) { item.conceptId = normalize(item.conceptId); } return documentValue; } function decodeStoredConceptMapDocument(conceptMap) { let documentValue = conceptMap.document; for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) { documentValue = JSON.parse(documentValue); } if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) { console.error("[racket-wiki:cmap-host 0.2.122] invalid stored CMap document", { slug: conceptMap.slug, valueType: Array.isArray(documentValue) ? "array" : typeof documentValue, value: documentValue }); throw new Error("The stored CMap document is not a JSON object."); } return normalizeCmapConceptIdentities(documentValue, conceptMap.slug || ""); } function currentCmapSnapshot() { const prototype = cmapPrototypeState(); if (!prototype.editor) return null; return JSON.stringify(prototype.editor.toDocument()); } function currentCmapStorageMap() { return state.currentConceptMapSource || state.currentConceptMap; } function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) { state.cmapSavedSnapshot = snapshot; } function cmapHasUnsavedChanges() { if ($("cmap-view").classList.contains("hidden")) return false; const currentSnapshot = currentCmapSnapshot(); return currentSnapshot !== null && state.cmapSavedSnapshot !== null && currentSnapshot !== state.cmapSavedSnapshot; } function cancelCmapAutosave() { if (cmapAutosaveTimer !== null) { window.clearTimeout(cmapAutosaveTimer); cmapAutosaveTimer = null; } } function scheduleCmapAutosave() { cancelCmapAutosave(); if (!can("editor") || !state.currentConceptMap || !cmapHasUnsavedChanges()) return; showCmapStatus(tr("autosave-pending", "Changes waiting to be saved")); cmapAutosaveTimer = window.setTimeout(() => { cmapAutosaveTimer = null; saveStoredConceptMap({ automatic: true }).catch((error) => console.error(error)); }, CMAP_AUTOSAVE_DELAY); } function updateStoredConceptMapSummary(conceptMap) { const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug); if (index >= 0) { state.conceptMaps[index] = { ...state.conceptMaps[index], ...conceptMap }; } else { state.conceptMaps.push(conceptMap); } renderConceptMapSelector(); } function continueCmapTransition() { const transition = pendingCmapTransition; if (!transition) return; pendingCmapTransition = null; $("cmap-unsaved-dialog").close(); Promise.resolve() .then(transition.action) .then(() => transition.resolve(true)) .catch((error) => { transition.reject(error); }); } function discardCmapChangesAndContinue() { markCurrentCmapSaved(); continueCmapTransition(); } function cancelCmapTransition() { const transition = pendingCmapTransition; pendingCmapTransition = null; $("cmap-unsaved-dialog").close(); if (transition) transition.resolve(false); renderConceptMapSelector(); } function requestCmapTransition(action) { if (!cmapHasUnsavedChanges()) { return Promise.resolve() .then(action) .then(() => true); } if (pendingCmapTransition) return Promise.resolve(false); return new Promise((resolve, reject) => { pendingCmapTransition = { action, resolve, reject }; $("cmap-unsaved-dialog").showModal(); }); } async function openStoredConceptMap(slug) { if (!slug) return; const loadSequence = ++state.cmapLoadSequence; showCmapStatus(tr("loading", "Loading…")); const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`); if (loadSequence !== state.cmapLoadSequence) return; conceptMap.document = decodeStoredConceptMapDocument(conceptMap); const derivedView = conceptMap.document.derivedView; let sourceMap = null; let editorDocument = conceptMap.document; if (derivedView && typeof derivedView === "object" && typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug && Number.isInteger(Number(derivedView.rootItemId))) { sourceMap = await api(`/api/cmaps/${encodeURIComponent(derivedView.sourceCmapSlug)}`); if (loadSequence !== state.cmapLoadSequence) return; sourceMap.document = decodeStoredConceptMapDocument(sourceMap); editorDocument = sourceMap.document; } console.info("[racket-wiki:cmap-host 0.2.122] stored CMap received", { slug: conceptMap.slug, version: conceptMap.currentVersion, itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0, connectorCount: Array.isArray(conceptMap.document.connectors) ? conceptMap.document.connectors.length : 0 }); state.currentConceptMap = conceptMap; state.currentConceptMapSource = sourceMap; renderConceptMapSelector(); resetCmapPrototype(editorDocument, false); if (sourceMap) { const editor = cmapPrototypeState().editor; const root = editor.itemRecord(derivedView.rootItemId); if (!root || root.kind !== "submap") { throw new Error("The source sub-CMap no longer exists."); } root.separateMap = true; root.cmapSlug = conceptMap.slug; root.childMap = conceptMap.title; if (!root.mapReference) { root.mapReference = { id: `cmap-${root.id}`, title: conceptMap.title, rootItemId: root.id, itemIds: editor.descendantItemRecords(root).map((item) => item.id) }; editor.setConceptMapReference(root.mapReference); } editor.openSubmapMap(root); } markCurrentCmapSaved(); const loadedEditor = cmapPrototypeState().editor; console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", { slug: conceptMap.slug, editorAvailable: Boolean(loadedEditor), itemCount: loadedEditor ? loadedEditor.itemCount() : 0, connectorCount: loadedEditor ? loadedEditor.connectorCount() : 0 }); showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true); } async function loadHistoricalConceptMapVersion(version) { const conceptMap = currentCmapStorageMap(); if (!conceptMap) return; const historical = await api( `/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`); historical.document = decodeStoredConceptMapDocument(historical); const currentSnapshot = JSON.stringify(conceptMap.document); resetCmapPrototype(historical.document, false); state.cmapSavedSnapshot = currentSnapshot; showCmapStatus( tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.") .replace("{version}", String(historical.version)), true); } async function showConceptMapHistory() { const conceptMap = currentCmapStorageMap(); if (!conceptMap) return; const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`); const list = $("cmap-history-list"); list.replaceChildren(); const versions = result.versions || []; if (!versions.length) { const empty = document.createElement("p"); empty.className = "muted cmap-history-empty"; empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet."); list.append(empty); } for (const version of versions) { const row = document.createElement("div"); row.className = "cmap-history-row"; const label = document.createElement("div"); const heading = document.createElement("strong"); heading.textContent = `${tr("version", "Version")} ${version.version} — ${version.title}`; const meta = document.createElement("div"); meta.className = "muted"; const knownSummaries = { create: tr("concept-map-created-version", "CMap created"), rename: tr("concept-map-renamed-version", "CMap renamed") }; let summary = knownSummaries[version.action] || version.summary; if (version.action === "snapshot") { const snapshotLabel = tr("snapshot", "Snapshot"); if (version.summary === "Current state when CMap history was enabled") { summary = tr("concept-map-initial-version", "Initial available version"); } else { summary = version.summary === snapshotLabel ? snapshotLabel : `${snapshotLabel} — ${version.summary}`; } } if (version.summary === "Automatic save") summary = tr("automatic-save", "Automatic save"); if (version.summary === "Manual save") summary = tr("manual-save", "Manual save"); meta.textContent = `${pageDisplayDate(version.createdAt)} · ${version.author} · ${summary}`; label.append(heading, document.createElement("br"), meta); const load = document.createElement("button"); load.type = "button"; load.textContent = version.version === conceptMap.currentVersion ? tr("current-version", "Current") : tr("load-version", "Load version"); load.disabled = version.version === conceptMap.currentVersion; load.addEventListener("click", () => { $("cmap-history-dialog").close(); requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version)) .catch((error) => showCmapStatus(error.message)); }); const actions = document.createElement("div"); actions.className = "cmap-history-actions"; actions.append(load); if (can("editor")) { const remove = document.createElement("button"); remove.type = "button"; remove.textContent = tr("delete-history-item", "Delete"); remove.addEventListener("click", async () => { const question = tr( "delete-concept-map-history-confirm", "Delete CMap history version {version}? The current CMap will not be changed.") .replace("{version}", String(version.version)); if (!window.confirm(question)) return; remove.disabled = true; try { await api( `/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version.version)}`, { method: "DELETE" }); row.remove(); if (!list.querySelector(".cmap-history-row")) { const empty = document.createElement("p"); empty.className = "muted cmap-history-empty"; empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet."); list.append(empty); } showCmapStatus(tr("concept-map-history-deleted", "History item deleted"), true); } catch (error) { remove.disabled = false; showCmapStatus(error.message); } }); actions.append(remove); } row.append(label, actions); list.append(row); } $("cmap-history-dialog").showModal(); } async function createStoredConceptMap() { const title = window.prompt(tr("concept-map-name", "Concept map name"), ""); if (!title || !title.trim()) return; const conceptMap = await api("/api/cmaps", { method: "POST", body: JSON.stringify({ title: title.trim(), document: { schemaVersion: 2, metadata: { tags: [], summary: "", explanationPageSlug: "" }, concepts: [], items: [], connectors: [], conceptMaps: [] } }) }); await loadConceptMaps(); location.hash = cmapRoute(conceptMap.slug); } async function promoteSelectedSubmapToStoredMap() { const prototype = cmapPrototypeState(); const editor = prototype.editor; const record = editor ? editor.selected() : null; if (!record || record.kind !== "submap") return false; const hasConcepts = editor.descendantItemRecords(record) .some((item) => item.kind !== "phrase"); if (!hasConcepts) { showCmapStatus(tr("empty-submap", "This sub-CMap has no concepts to move.")); return false; } const sourceMap = currentCmapStorageMap(); let linkedMap = null; let title = null; if (record.cmapSlug) { linkedMap = state.currentConceptMap?.slug === record.cmapSlug ? { ...state.currentConceptMap } : await api(`/api/cmaps/${encodeURIComponent(record.cmapSlug)}`); linkedMap.document = decodeStoredConceptMapDocument(linkedMap); const derivedView = linkedMap.document.derivedView; const matchesSource = sourceMap && derivedView && derivedView.sourceCmapSlug === sourceMap.slug && Number(derivedView.rootItemId) === Number(record.id); if (!matchesSource) { showCmapStatus(tr("submap-already-independent", "This linked CMap is already independent."), true); await navigateToHash(cmapRoute(record.cmapSlug)); return true; } } else { title = window.prompt( tr("submap-name", "Name of the new concept map"), record.childMap || record.label); if (!title || !title.trim()) return false; } const buttons = [$("cmap-promote-submap"), $("cmap-extract-selected")]; for (const button of buttons) button.disabled = true; try { const sourceSaved = await saveStoredConceptMap({ force: true, snapshotVersion: true, summary: tr("before-submap-extraction", "Before extracting sub-CMap") }); if (!sourceSaved) return false; let storedMap = linkedMap; let extraction; if (storedMap) { extraction = editor.prepareStoredSubmapExtraction( record, storedMap.slug, storedMap.document.metadata); storedMap = await api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, { method: "PUT", body: JSON.stringify({ title: storedMap.title, baseVersion: storedMap.currentVersion, summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap"), saveKind: "manual", document: extraction.childDocument }) }); } else { const prepared = editor.prepareStoredSubmapExtraction(record, null); if (!prepared) return false; storedMap = await api("/api/cmaps", { method: "POST", body: JSON.stringify({ title: title.trim(), document: prepared.childDocument }) }); extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug); } editor.replaceDocument(extraction.parentDocument); const parentSaved = await saveStoredConceptMap({ force: true, historyMode: "autosave", summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap") }); if (!parentSaved) { showCmapStatus(tr("submap-created-parent-unsaved", "The new CMap was created, but the parent CMap still needs to be saved.")); return false; } await navigateToHash(cmapRoute(storedMap.slug)); return true; } finally { const selected = editor.selected(); const canExtractSubmap = selected && selected.kind === "submap"; for (const button of buttons) button.disabled = !canExtractSubmap; $("cmap-extract-selected").classList.toggle("hidden", !canExtractSubmap); } } 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; } } function activeCmapMetadata() { const currentDocument = state.currentConceptMap?.document; if (currentDocument?.derivedView) { const metadata = currentDocument.metadata || {}; return { tags: Array.isArray(metadata.tags) ? metadata.tags : [], summary: metadata.summary || "", explanationPageSlug: metadata.explanationPageSlug || "" }; } const editor = cmapPrototypeState().editor; return editor?.getDocumentMetadata ? editor.getDocumentMetadata() : { tags: [], summary: "", explanationPageSlug: "" }; } function openCmapMetadataDialog() { if (!state.currentConceptMap) return; const metadata = activeCmapMetadata(); $("cmap-metadata-summary").value = metadata.summary || ""; $("cmap-metadata-tags").value = (metadata.tags || []).join(", "); $("cmap-metadata-explanation-page").value = metadata.explanationPageSlug || ""; $("cmap-metadata-explanation-page").setCustomValidity(""); $("cmap-metadata-dialog").showModal(); $("cmap-metadata-summary").focus(); closeCmapContextMenu(); } async function saveCmapMetadata() { const conceptMap = state.currentConceptMap; const editor = cmapPrototypeState().editor; if (!conceptMap || !editor) return false; const explanationInput = $("cmap-metadata-explanation-page").value.trim(); const explanationPageSlug = explanationInput ? newPageReference(explanationInput) : ""; if (explanationInput && !explanationPageSlug) { const input = $("cmap-metadata-explanation-page"); input.setCustomValidity(tr("invalid-description-page", "Enter a valid description page address.")); input.reportValidity(); return false; } const metadata = { tags: $("cmap-metadata-tags").value.split(",") .map((tag) => tag.trim()).filter(Boolean), summary: $("cmap-metadata-summary").value.trim(), explanationPageSlug }; if (conceptMap.document?.derivedView) { const updated = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { method: "PUT", body: JSON.stringify({ title: conceptMap.title, baseVersion: conceptMap.currentVersion, summary: tr("updated-concept-map-details", "Updated CMap details"), saveKind: "manual", document: { ...conceptMap.document, metadata } }) }); updated.document = decodeStoredConceptMapDocument(updated); state.currentConceptMap = updated; updateStoredConceptMapSummary(updated); } else { editor.setDocumentMetadata(metadata); if (!await saveStoredConceptMap({ force: true, summary: tr("updated-concept-map-details", "Updated CMap details") })) return false; } $("cmap-metadata-dialog").close(); showCmapStatus(tr("concept-map-details-saved", "CMap details saved"), true); return true; } function openCmapExportDialog() { if (!state.currentConceptMap) return; $("cmap-export-status").textContent = ""; $("cmap-export-dialog").showModal(); $("cmap-export-depth").focus(); closeCmapContextMenu(); } async function buildCurrentCmapMarkdownExport() { if (!state.currentConceptMap || !window.RacketWikiCmapExport) { throw new Error(tr("cmap-export-unavailable", "CMap export is unavailable.")); } if (cmapHasUnsavedChanges() && can("editor")) { const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }); if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export.")); } const rootMap = await api(`/api/cmaps/${encodeURIComponent(state.currentConceptMap.slug)}`); rootMap.document = decodeStoredConceptMapDocument(rootMap); const depth = Math.max(0, Math.min(10, Number($("cmap-export-depth").value) || 0)); return window.RacketWikiCmapExport.generateMarkdown({ rootMap, maxDepth: depth, includeWikiPages: $("cmap-export-pages").checked, language: state.language, loadConceptMap: async (slug) => { const map = await api(`/api/cmaps/${encodeURIComponent(slug)}`); map.document = decodeStoredConceptMapDocument(map); return map; }, loadWikiPage: (reference) => api(`/api/pages/${encodeURIComponent(reference)}`) }); } async function buildCurrentCmapJsonExport() { if (!state.currentConceptMap) { throw new Error(tr("cmap-json-unavailable", "CMap JSON export is unavailable.")); } if (cmapHasUnsavedChanges() && can("editor")) { const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }); if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export.")); } const rootMap = await api(`/api/cmaps/${encodeURIComponent(state.currentConceptMap.slug)}`); rootMap.document = decodeStoredConceptMapDocument(rootMap); const depth = Math.max(0, Math.min(10, Number($("cmap-export-depth").value) || 0)); return buildBundle({ rootMap, maxDepth: depth, generator: "Racket Wiki 0.2.122", loadConceptMap: async (slug) => { const map = await api(`/api/cmaps/${encodeURIComponent(slug)}`); map.document = decodeStoredConceptMapDocument(map); return map; }, loadWikiPage: (reference) => api(`/api/pages/${encodeURIComponent(reference)}`), loadAttachment: async (url) => { const response = await fetch(url, { credentials: "same-origin" }); if (!response.ok) throw new Error(`Attachment could not be exported: ${url}`); const content = await response.arrayBuffer(); return { mimeType: response.headers.get("content-type") || "application/octet-stream", contentBase64: arrayBufferToBase64(content) }; } }); } function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ""; const chunkSize = 0x8000; for (let offset = 0; offset < bytes.length; offset += chunkSize) { binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); } return window.btoa(binary); } function base64AttachmentBlob(attachment) { const binary = window.atob(String(attachment.contentBase64 || "")); if (binary.length > 50 * 1024 * 1024) { throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`); } const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return new Blob([bytes], { type: attachment.mimeType || "application/octet-stream" }); } async function importPageAttachments(page) { const replacements = new Map(); for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) { const uploaded = await api(`/api/pages/${encodeURIComponent(page.reference)}/upload`, { method: "POST", headers: { "X-File-Name": attachment.name }, body: base64AttachmentBlob(attachment) }); replacements.set(attachment.url, uploaded.url); } return replaceAttachmentUrls(page.markdown, replacements); } function downloadTextFile(text, filename, contentType) { const blob = new Blob([text], { type: contentType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); } async function exportCurrentCmapJson() { const status = $("cmap-export-status"); status.textContent = tr("preparing-cmap-json", "Preparing complete CMap JSON…"); try { const bundle = await buildCurrentCmapJsonExport(); downloadTextFile(`${JSON.stringify(bundle, null, 2)}\n`, `${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8"); status.textContent = tr("cmap-json-downloaded", "CMap JSON downloaded."); return true; } catch (error) { status.textContent = error.message; return false; } } async function importCmapBundleFile(file) { if (file.size > 256 * 1024 * 1024) { throw new Error(tr("cmap-json-too-large", "The CMap JSON file exceeds 256 MiB.")); } const bundle = JSON.parse(await file.text()); validateBundle(bundle); if (cmapHasUnsavedChanges()) { const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }); if (!saved) throw new Error(tr("save-before-import-failed", "The current CMap could not be saved before import.")); } const existingPages = new Map(state.pages.map((page) => [page.slug, page])); const existingMaps = new Map(state.conceptMaps.map((cmap) => [cmap.slug, cmap])); const pageConflicts = bundle.pages.filter((page) => existingPages.has(page.reference)); const mapConflicts = bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug)); let replaceExisting = false; if (pageConflicts.length || mapConflicts.length) { replaceExisting = window.confirm(tr( "cmap-import-conflicts", "The import contains {maps} existing CMaps and {pages} existing pages. Choose OK to replace them with the imported content, or Cancel to keep them and import only new records.") .replace("{maps}", mapConflicts.length) .replace("{pages}", pageConflicts.length)); } const result = { mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0, pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0, attachmentsImported: 0 }; for (const page of bundle.pages) { const existing = existingPages.get(page.reference); if (existing && !replaceExisting) { result.pagesSkipped += 1; continue; } const attachments = Array.isArray(page.attachments) ? page.attachments : []; const body = { slug: page.reference, title: page.title, markdown: page.markdown, tags: page.tags, summary: tr("imported-from-cmap-json", "Imported from CMap JSON") }; if (existing) { body.markdown = await importPageAttachments(page); body.baseVersion = existing.currentVersion; await api(`/api/pages/${encodeURIComponent(page.reference)}`, { method: "PUT", body: JSON.stringify(body) }); result.pagesUpdated += 1; } else { const created = await api("/api/pages", { method: "POST", body: JSON.stringify(body) }); if (attachments.length) { body.markdown = await importPageAttachments(page); body.baseVersion = created.currentVersion; await api(`/api/pages/${encodeURIComponent(page.reference)}`, { method: "PUT", body: JSON.stringify(body) }); } result.pagesCreated += 1; } result.attachmentsImported += attachments.length; } for (const cmap of bundle.cmaps) { const existing = existingMaps.get(cmap.slug); if (existing && !replaceExisting) { result.mapsSkipped += 1; continue; } const documentValue = preparedMapDocument(bundle, cmap); if (existing) { await api(`/api/cmaps/${encodeURIComponent(cmap.slug)}`, { method: "PUT", body: JSON.stringify({ title: cmap.title, document: documentValue, baseVersion: existing.currentVersion, saveKind: "manual", summary: tr("imported-from-cmap-json", "Imported from CMap JSON") }) }); result.mapsUpdated += 1; } else { await api("/api/cmaps", { method: "POST", body: JSON.stringify({ slug: cmap.slug, title: cmap.title, document: documentValue }) }); result.mapsCreated += 1; } } await loadPages(); await loadConceptMaps(); await navigateToHash(cmapRoute(bundle.rootCmapSlug)); return result; } async function handleCmapImportFile(file) { showCmapStatus(tr("importing-cmap-json", "Importing CMap JSON…")); try { const result = await importCmapBundleFile(file); showCmapStatus(tr( "cmap-json-imported", "Import complete: {maps} CMaps, {pages} pages and {attachments} attachments imported; {skipped} existing records kept.") .replace("{maps}", result.mapsCreated + result.mapsUpdated) .replace("{pages}", result.pagesCreated + result.pagesUpdated) .replace("{attachments}", result.attachmentsImported) .replace("{skipped}", result.mapsSkipped + result.pagesSkipped), true); } catch (error) { showCmapStatus(error.message); window.alert(error.message); } } async function copyText(text) { try { await navigator.clipboard.writeText(text); } catch (_error) { const input = document.createElement("textarea"); input.value = text; input.style.position = "fixed"; input.style.left = "-10000px"; document.body.append(input); input.select(); document.execCommand("copy"); input.remove(); } } async function exportCurrentCmap(download) { const status = $("cmap-export-status"); status.textContent = tr("preparing-markdown-export", "Preparing Markdown export…"); try { const markdown = await buildCurrentCmapMarkdownExport(); if (download) { const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${state.currentConceptMap.slug}-report.md`; document.body.append(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); status.textContent = tr("markdown-export-downloaded", "Markdown export downloaded."); } else { await copyText(markdown); status.textContent = tr("markdown-export-copied", "Markdown export copied."); } return true; } catch (error) { status.textContent = error.message; return false; } } async function deleteStoredConceptMap() { cancelCmapAutosave(); if (cmapSavePromise) await cmapSavePromise; const conceptMap = state.currentConceptMap; if (!conceptMap) return false; const question = tr( "archive-concept-map-confirm", "Archive the entire concept map \"{title}\"? It will disappear from normal navigation, but an administrator can restore it.") .replace("{title}", conceptMap.title); if (!window.confirm(question)) return false; const typedTitle = window.prompt( tr( "archive-concept-map-type-title", "Type the complete CMap name to confirm: {title}") .replace("{title}", conceptMap.title), ""); if (typedTitle === null) return false; if (typedTitle !== conceptMap.title) { showCmapStatus(tr("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived.")); return false; } try { await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { method: "DELETE", body: JSON.stringify({ confirmTitle: typedTitle, baseVersion: conceptMap.currentVersion }) }); markCurrentCmapSaved(); state.currentConceptMap = null; state.currentConceptMapSource = null; await loadConceptMaps(); const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps"; await navigateToHash(target); showCmapStatus(tr("concept-map-archived", "CMap archived"), true); return true; } catch (error) { showCmapStatus(error.message); return false; } } async function saveStoredConceptMap({ automatic = false, force = false, summary = null, snapshotVersion = false, historyMode = null } = {}) { const prototype = cmapPrototypeState(); if (!prototype.editor) return false; cancelCmapAutosave(); if (automatic && !currentCmapStorageMap()) return false; const effectiveHistoryMode = historyMode || (snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual")); if (cmapSavePromise) { const firstSaveSucceeded = await cmapSavePromise; if (!firstSaveSucceeded) return false; if (force || cmapHasUnsavedChanges() || effectiveHistoryMode !== "autosave") { return saveStoredConceptMap({ automatic, force, summary, snapshotVersion, historyMode }); } return true; } const snapshot = currentCmapSnapshot(); if (snapshot === null) return false; if (!force && snapshot === state.cmapSavedSnapshot && effectiveHistoryMode === "autosave") { return true; } const document = JSON.parse(snapshot); const conceptMapAtStart = currentCmapStorageMap(); let saveSucceeded = false; showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") : (automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…"))); cmapSavePromise = (async () => { try { if (!conceptMapAtStart) { const title = window.prompt(tr("concept-map-name", "Concept map name"), ""); if (!title || !title.trim()) { showCmapStatus(""); return false; } state.currentConceptMap = await api("/api/cmaps", { method: "POST", body: JSON.stringify({ title: title.trim(), document }) }); const savedRoute = cmapRoute(state.currentConceptMap.slug); history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`); state.cmapGuardHash = savedRoute; await loadConceptMaps(); } else { const savedConceptMap = await api( `/api/cmaps/${encodeURIComponent(conceptMapAtStart.slug)}`, { method: "PUT", body: JSON.stringify({ title: conceptMapAtStart.title, baseVersion: conceptMapAtStart.currentVersion, summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")), snapshot: snapshotVersion, saveKind: effectiveHistoryMode, document }) }); if (state.currentConceptMapSource && state.currentConceptMapSource.slug === conceptMapAtStart.slug) { state.currentConceptMapSource = savedConceptMap; updateStoredConceptMapSummary(savedConceptMap); } else if (state.currentConceptMap && state.currentConceptMap.slug === conceptMapAtStart.slug) { state.currentConceptMap = savedConceptMap; updateStoredConceptMapSummary(savedConceptMap); } } markCurrentCmapSaved(snapshot); await loadConceptMaps(); showCmapStatus( snapshotVersion ? tr("snapshot-created", "Snapshot created") : (automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")), true); saveSucceeded = true; return true; } catch (error) { showCmapStatus(error.message); return false; } })().finally(() => { cmapSavePromise = null; if (saveSucceeded && cmapHasUnsavedChanges()) scheduleCmapAutosave(); }); return cmapSavePromise; } async function createConceptMapSnapshot() { if (!currentCmapStorageMap()) return false; const description = window.prompt(tr("snapshot-description", "Snapshot description"), ""); if (description === null) return false; return saveStoredConceptMap({ force: true, summary: description.trim() || tr("snapshot", "Snapshot"), snapshotVersion: true }); } async function showCmapPrototype(requestedSlug = null) { state.previousView = state.currentPage ? "page-view" : "cmap-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("cmaps", "CMaps") } ]); renderToc([], () => {}); show("cmap-view"); state.cmapGuardHash = location.hash; await loadConceptMaps(); if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) { await openStoredConceptMap(requestedSlug); } else if (requestedSlug) { state.currentConceptMap = null; state.currentConceptMapSource = null; resetCmapPrototype(null, false); markCurrentCmapSaved(); renderConceptMapSelector(); showCmapStatus(tr("concept-map-not-found", "CMap not found")); } else if (state.conceptMaps.length) { const preferred = startCmapSlug(); const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) || state.conceptMaps[0]; await openStoredConceptMap(target.slug); } else { state.currentConceptMap = null; state.currentConceptMapSource = null; resetCmapPrototype(); markCurrentCmapSaved(); renderConceptMapSelector(); } } function openSelectedConceptMap() { const slug = cmapMapCombobox.value(); if (!slug) return; if (state.currentConceptMap && slug === state.currentConceptMap.slug) return; navigateToHash(cmapRoute(slug)) .then((changed) => { if (!changed) renderConceptMapSelector(); }) .catch((error) => { showCmapStatus(error.message); console.error(error); }); } $("cmap-map-select").addEventListener("change", openSelectedConceptMap); $("cmap-wiki-reference").addEventListener("click", async (event) => { const markdown = event.currentTarget.dataset.markdown || ""; if (!markdown) return; try { await navigator.clipboard.writeText(markdown); } catch (_error) { const input = document.createElement("textarea"); input.value = markdown; input.style.position = "fixed"; input.style.left = "-10000px"; document.body.append(input); input.select(); document.execCommand("copy"); input.remove(); } showCmapStatus(tr("wiki-link-copied", "Wiki link copied")); }); $("cmap-set-start-map").addEventListener("click", () => { if (!state.currentConceptMap) return; setStartCmapSlug(state.currentConceptMap.slug); renderConceptMapSelector(); showCmapStatus(tr("start-concept-map-set", "Start CMap set"), true); closeCmapContextMenu(); }); $("cmap-new-map").addEventListener("click", () => { requestCmapTransition(() => createStoredConceptMap()) .catch((error) => { showCmapStatus(error.message); }); }); $("cmap-save-map").addEventListener("click", () => saveStoredConceptMap()); $("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap()); $("cmap-edit-metadata").addEventListener("click", openCmapMetadataDialog); $("cmap-export-markdown").addEventListener("click", openCmapExportDialog); $("cmap-import-json").addEventListener("click", () => { closeCmapContextMenu(); $("cmap-import-file").value = ""; $("cmap-import-file").click(); }); $("cmap-manage-people").addEventListener("click", () => { openPeopleManagement().catch((error) => showCmapStatus(error.message)); }); $("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap()); $("cmap-create-snapshot").addEventListener("click", () => { createConceptMapSnapshot().catch((error) => showCmapStatus(error.message)); }); $("cmap-history").addEventListener("click", () => { showConceptMapHistory().catch((error) => showCmapStatus(error.message)); }); $("cmap-add-concept").addEventListener("click", () => { openNewCmapConceptDialog(cmapContextCreateContext || {}); }); $("cmap-add-page").addEventListener("click", () => { if (!state.currentPage) return; addCmapPrototypeNode({ ...cmapPlacementOptions(), label: state.currentPage.title, synopsis: currentPageSynopsis(), kind: "page", pageSlug: state.currentPage.slug, backgroundColor: "#e7f2fb", borderColor: "#4479a1" }); }); $("cmap-add-submap").addEventListener("click", () => { const label = window.prompt(tr("add-submap", "Add sub-CMap"), tr("sub-concept-map", "Sub concept map")); if (!label) return; addCmapPrototypeNode({ ...cmapPlacementOptions(), label, synopsis: "Expandable child-map placeholder.", kind: "submap", childMap: label, backgroundColor: "#edf7e8", borderColor: "#57834a" }); }); for (const id of ["cmap-promote-submap", "cmap-extract-selected"]) { $(id).addEventListener("click", () => { promoteSelectedSubmapToStoredMap().catch((error) => { console.error(error); showCmapStatus(error.message); }); }); } $("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode()); $("cmap-cut-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.cutSelectionReferences(); }); $("cmap-copy-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.copySelectionReferences(); }); $("cmap-paste-concepts").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.pasteConceptReferences(); }); $("cmap-undo").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.undo(); }); $("cmap-redo").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.redo(); }); $("cmap-select-all").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.selectAll(); }); $("cmap-group-selected").addEventListener("click", () => { groupSelectedCmapItems(); }); $("cmap-ungroup-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.ungroupSelection(); }); $("cmap-hide-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.hideSelectionInCurrentContext(); }); $("cmap-delete-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.deleteSelection(); }); $("cmap-selection-toolbar").addEventListener("click", (event) => { const button = event.target.closest("button"); if (!button || button.disabled) return; const prototype = cmapPrototypeState(); const editor = prototype.editor; if (!editor) return; const layoutCommand = button.dataset.cmapLayout; if (layoutCommand) { editor.applySelectionLayout(layoutCommand); return; } switch (button.dataset.cmapSelectionAction) { case "edit": editSelectedCmapNode(); break; case "group": groupSelectedCmapItems(); break; case "ungroup": editor.ungroupSelection(); break; case "hide": editor.hideSelectionInCurrentContext(); break; default: break; } }); $("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.canStepBackWithinMap()) { prototype.editor.openParentMap(); return; } if (state.currentConceptMapSource) { navigateToHash(cmapRoute(state.currentConceptMapSource.slug)) .catch((error) => console.error(error)); return; } if (prototype.editor) prototype.editor.openParentMap(); }); $("cmap-canvas").addEventListener("contextmenu", (event) => { event.preventDefault(); const prototype = cmapPrototypeState(); if (!prototype.editor) return; const point = prototype.editor.canvasPoint(event); openCmapContextMenu(event.clientX, event.clientY, { point, parentSubmap: prototype.editor.submapAtPoint(point) }); }); $("cmap-canvas").addEventListener("pointerover", (event) => { const button = event.target.closest(".rw-cmap-view-description"); if (button) showCmapDescriptionTooltip(button); }); $("cmap-canvas").addEventListener("pointerout", (event) => { if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip(); }); $("cmap-canvas").addEventListener("focusin", (event) => { const button = event.target.closest(".rw-cmap-view-description"); if (button) showCmapDescriptionTooltip(button); }); $("cmap-canvas").addEventListener("focusout", (event) => { if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip(); }); $("cmap-tools-menu").addEventListener("click", (event) => { const prototype = cmapPrototypeState(); if (!prototype.editor) return; if (!$("cmap-context-menu").classList.contains("hidden")) { closeCmapContextMenu(); return; } const buttonRect = event.currentTarget.getBoundingClientRect(); const canvasRect = $("cmap-canvas").getBoundingClientRect(); const point = prototype.editor.canvasPoint({ clientX: canvasRect.left + 160, clientY: Math.max(canvasRect.top, buttonRect.bottom) + 80 }); openCmapContextMenu(buttonRect.left, buttonRect.bottom + 4, { point, parentSubmap: prototype.editor.submapAtPoint(point) }); }); $("cmap-context-menu").addEventListener("click", () => closeCmapContextMenu()); document.addEventListener("pointerdown", (event) => { if (!event.target.closest("#cmap-context-menu, #cmap-tools-menu")) closeCmapContextMenu(); if (!event.target.closest(".cmap-color-control")) { for (const palette of document.querySelectorAll(".cmap-color-palette")) { palette.classList.add("hidden"); } } }); $("cmap-zoom-out").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) - 10)); $("cmap-zoom-in").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) + 10)); $("cmap-zoom-reset").addEventListener("click", () => setCmapZoom(100)); $("cmap-zoom-percent").addEventListener("change", (event) => setCmapZoom(event.target.value)); $("cmap-concept-page").addEventListener("change", (event) => { if (cmapPageCombobox.value()) cmapLinkCombobox.clear(); }); $("cmap-concept-cmap").addEventListener("change", (event) => { if (cmapLinkCombobox.value()) cmapPageCombobox.clear(); }); for (const id of ["cmap-concept-page", "cmap-concept-cmap"]) { $(id).addEventListener("input", (event) => event.target.setCustomValidity("")); } $("cmap-concept-description-page").addEventListener("input", (event) => event.target.setCustomValidity("")); $("cmap-concept-description-link").addEventListener("click", () => $("cmap-concept-dialog").close()); installCmapColorPickers(); $("cmap-concept-quick-style").addEventListener("change", applySelectedCmapStyle); $("cmap-concept-style-preset").addEventListener("change", applySelectedCmapStyle); $("cmap-save-style").addEventListener("click", saveCurrentCmapStyle); $("cmap-delete-style").addEventListener("click", deleteSelectedCmapStyle); for (const eventName of ["input", "change"]) { $("cmap-concept-panel-appearance").addEventListener(eventName, (event) => { if (!event.target.closest(".cmap-style-manager")) syncCmapStyleSelection(); }); } $("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-form").querySelector(".cmap-concept-tabs").addEventListener("click", (event) => { const tab = event.target.closest("[data-cmap-concept-tab]"); if (tab) selectCmapConceptTab(tab.dataset.cmapConceptTab); }); $("cmap-concept-form").querySelector(".cmap-concept-tabs").addEventListener("keydown", (event) => { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; const tabs = Array.from(event.currentTarget.querySelectorAll("[data-cmap-concept-tab]")); const current = tabs.indexOf(event.target.closest("[data-cmap-concept-tab]")); if (current < 0) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; selectCmapConceptTab(tabs[next].dataset.cmapConceptTab); tabs[next].focus(); }); $("cmap-concept-external-url").addEventListener("input", (event) => event.currentTarget.setCustomValidity("")); $("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) { selectCmapConceptTab("content"); $("cmap-concept-label").focus(); return; } const fontSize = normalizedCmapFontSize($("cmap-concept-font-size").value, 11); const synopsisFontSize = normalizedCmapFontSize( $("cmap-concept-synopsis-font-size").value, 9); const pageInput = $("cmap-concept-page"); const cmapInput = $("cmap-concept-cmap"); const selectedPage = record && record.kind === "submap" ? "" : cmapPageCombobox.value(); const selectedCmap = record && record.kind === "submap" ? "" : cmapLinkCombobox.value(); const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage; if (selectedPage === null && !linkedPage) { selectCmapConceptTab("content"); pageInput.setCustomValidity(tr("invalid-new-page", "Enter a page title or valid wiki address.")); pageInput.reportValidity(); return; } if (selectedCmap === null) { selectCmapConceptTab("content"); cmapInput.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field.")); cmapInput.reportValidity(); return; } const linkedPageValue = linkedPage || null; const externalUrlInput = $("cmap-concept-external-url"); const externalUrl = normalizedCmapExternalUrl(externalUrlInput.value); if (externalUrl === null) { selectCmapConceptTab("content"); externalUrlInput.setCustomValidity(tr( "invalid-external-web-page", "Enter a complete http or https web address.")); externalUrlInput.reportValidity(); externalUrlInput.focus(); return; } const descriptionInput = $("cmap-concept-description-page").value.trim(); const descriptionPage = descriptionInput ? newPageReference(descriptionInput) : pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug); if (!descriptionPage) { selectCmapConceptTab("content"); $("cmap-concept-description-page").setCustomValidity( tr("invalid-description-page", "Enter a valid description page address.")); $("cmap-concept-description-page").reportValidity(); return; } const linkedCmapValue = selectedCmap || ""; const parentCmapLink = linkedCmapValue === "__parent__"; const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null; const existingNonPersonTags = record && Array.isArray(record.tags) ? record.tags.filter((tag) => !(tag && typeof tag === "object" && tag.type === "person")) : []; const personTags = selectedCmapPersonNames() .map((name) => ({ type: "person", value: name })); const changes = { kind: record && record.kind === "submap" ? "submap" : (linkedPageValue ? "page" : "concept"), label, synopsis: $("cmap-concept-synopsis").value, aspects: $("cmap-concept-aspects").value.split(",") .map((aspect) => aspect.trim()).filter(Boolean), tags: [...existingNonPersonTags, ...personTags], descriptionPageSlug: descriptionPage, pageSlug: linkedPageValue, cmapSlug: linkedCmap, externalUrl: externalUrl || null, parentCmapLink, imageSource: cmapDialogImageSource, backgroundColor: cmapColorValue($("cmap-concept-background").value), textColor: cmapColorValue($("cmap-concept-text-color").value, "#222222"), fontFamily: $("cmap-concept-font-family").value || "Arial, Helvetica, sans-serif", fontSize: `${fontSize}pt`, fontWeight: $("cmap-concept-bold").checked ? "700" : "400", fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal", synopsisTextColor: cmapColorValue( $("cmap-concept-synopsis-text-color").value, "#4d4d4d"), synopsisFontFamily: $("cmap-concept-synopsis-font-family").value || "Arial, Helvetica, sans-serif", synopsisFontSize: `${synopsisFontSize}pt`, synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400", synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal" }; const existingConceptId = sharedCmapConceptId(label); if (!record || record.kind !== "submap") { changes.borderColor = linkedPageValue ? "#4479a1" : ((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00"); } else { changes.submapBackgroundColor = cmapColorValue($("cmap-submap-background").value, "#edf7e8"); changes.submapBorderColor = cmapColorValue($("cmap-submap-border").value, "#57834a"); } if (record) { if (existingConceptId && record.conceptId !== existingConceptId) { const previousConceptId = record.conceptId; record.conceptId = existingConceptId; prototype.editor.refreshConceptUsageIndicators( [previousConceptId, existingConceptId].filter(Boolean)); } prototype.editor.updateItem(record, changes); prototype.editor.selectItem(record); } else { if (existingConceptId) changes.conceptId = existingConceptId; 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); } } prototype.editor.commitHistory(); $("cmap-concept-dialog").close(); if (currentCmapStorageMap()) { await saveStoredConceptMap({ automatic: true }); } }); $("cmap-concept-dialog").addEventListener("close", () => { cmapDialogRecord = null; cmapDialogCreateContext = null; cmapDialogImageSource = ""; cmapDialogImageRead = Promise.resolve(); }); $("cmap-person-tag-options").addEventListener("change", () => renderCmapPersonTagOptions(selectedCmapPersonNames())); $("cmap-person-add").addEventListener("click", () => { createPersonFromInput("cmap-person-new-name", true) .catch((error) => window.alert(error.message)); }); $("cmap-person-new-name").addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); createPersonFromInput("cmap-person-new-name", true) .catch((error) => window.alert(error.message)); }); $("cmap-people-add").addEventListener("click", () => { createPersonFromInput("cmap-people-new-name") .then(() => renderPeopleManagement()) .catch((error) => window.alert(error.message)); }); $("cmap-people-new-name").addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); createPersonFromInput("cmap-people-new-name") .then(() => renderPeopleManagement()) .catch((error) => window.alert(error.message)); }); $("cmap-people-close").addEventListener("click", () => $("cmap-people-dialog").close()); $("cmap-metadata-cancel").addEventListener("click", () => $("cmap-metadata-dialog").close()); $("cmap-metadata-form").addEventListener("submit", (event) => { event.preventDefault(); saveCmapMetadata().catch((error) => showCmapStatus(error.message)); }); $("cmap-metadata-explanation-page").addEventListener("input", (event) => event.currentTarget.setCustomValidity("")); $("cmap-export-cancel").addEventListener("click", () => $("cmap-export-dialog").close()); $("cmap-export-copy").addEventListener("click", () => exportCurrentCmap(false)); $("cmap-export-json").addEventListener("click", () => exportCurrentCmapJson()); $("cmap-export-form").addEventListener("submit", (event) => { event.preventDefault(); exportCurrentCmap(true); }); $("cmap-import-file").addEventListener("change", (event) => { const file = event.currentTarget.files && event.currentTarget.files[0]; if (file) handleCmapImportFile(file); }); $("cmap-unsaved-save").addEventListener("click", async () => { if (await saveStoredConceptMap()) continueCmapTransition(); }); $("cmap-unsaved-discard").addEventListener("click", discardCmapChangesAndContinue); $("cmap-unsaved-cancel").addEventListener("click", cancelCmapTransition); $("cmap-unsaved-dialog").addEventListener("cancel", (event) => { event.preventDefault(); cancelCmapTransition(); }); $("cmap-history-close").addEventListener("click", () => $("cmap-history-dialog").close()); document.addEventListener("keydown", (event) => { 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); }); /** * Load persistent CMap preferences and the data needed by the workspace. */ this.initialize = async () => { await loadCmapStyles(); restoreCmapPageGuides(); await loadConceptMaps(); await loadPeople(); }; this.renderNodeHtml = cmapNodeHtml; this.queueEmbedHydration = queueCmapEmbedHydration; this.loadConceptMaps = loadConceptMaps; this.conceptMapEntry = titledCmapComboboxEntry; this.normalizeExternalUrl = normalizedCmapExternalUrl; this.clearDescriptionPreviews = () => cmapDescriptionPreviewCache.clear(); this.requestTransition = requestCmapTransition; this.show = showCmapPrototype; this.hasUnsavedChanges = cmapHasUnsavedChanges; this.startSlug = startCmapSlug; } }