import { newPageReference } from "../reference.js"; import { cmapRoute, pageRoute } from "../routes.js"; import { cmapMentionTarget, escapeHtml } from "../markdown.js"; import { CmapModel } from "../../../cmap/model/concept-map.js"; import { CmapAppearanceRepository } from "../../../cmap/model/appearance-repository.js"; import { CmapRepository } from "../../../cmap/model/cmap-repository.js"; import { CmapJsonExporter } from "../../../cmap/model/json-exporter.js"; import { CmapJsonImporter } from "../../../cmap/model/json-importer.js"; import { CmapMarkdownExporter } from "../../../cmap/model/markdown-exporter.js"; import { CmapSettingsRepository } from "../../../cmap/model/settings-repository.js"; import { PeopleRepository } from "../../../cmap/model/people-repository.js"; import { CmapAppearanceEditor } from "../../../cmap/view/appearance-editor.js"; import { ComboBox } from "../../widgets/combobox.js"; import { DescriptionPreview } from "../../widgets/description-preview.js"; import { PopupMenu } from "../../widgets/popup-menu.js"; import { StatusField } from "../../widgets/status-field.js"; import { CmapConceptDialog } from "./dialogs/concept-dialog.js"; import { CmapExportDialog } from "./dialogs/export-dialog.js"; import { CmapHistoryDialog } from "./dialogs/history-dialog.js"; import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js"; import { CmapPeopleDialog } from "./dialogs/people-dialog.js"; import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.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 ComboBox($("cmap-map-combobox")); const cmapContextMenu = new PopupMenu( $("cmap-context-menu"), $("cmap-tools-menu")); const cmapStatus = new StatusField($("cmap-save-status"), "cmap-status-visible"); const cmapDescriptionPreview = new DescriptionPreview( (reference) => api(`/api/pages/${encodeURIComponent(reference)}`), renderMarkdown); const cmapRepository = new CmapRepository(api); const appearanceRepository = new CmapAppearanceRepository(api); const settingsRepository = new CmapSettingsRepository(api); const peopleRepository = new PeopleRepository(api); let appearanceEditor = null; let conceptDialog = null; const loadExportWikiPage = (reference) => api(`/api/pages/${encodeURIComponent(reference)}`); const markdownExporter = new CmapMarkdownExporter( cmapRepository, loadExportWikiPage); const jsonExporter = new CmapJsonExporter( cmapRepository, loadExportWikiPage, (url) => fetch(url, { credentials: "same-origin" }), "Racket Wiki 0.2.122"); const jsonImporter = new CmapJsonImporter(cmapRepository, api, tr); const peopleDialog = new CmapPeopleDialog( $("cmap-people-dialog"), $("cmap-person-tags-picker"), peopleRepository, tr); const metadataDialog = new CmapMetadataDialog( $("cmap-metadata-dialog"), tr, newPageReference) .onSave(async (metadata) => { try { return await saveCmapMetadata(metadata); } catch (error) { showCmapStatus(error.message); return false; } }); const historyDialog = new CmapHistoryDialog( $("cmap-history-dialog"), cmapRepository, tr, pageDisplayDate, can, (version) => { requestCmapTransition(() => loadHistoricalConceptMapVersion(version)) .catch((error) => showCmapStatus(error.message)); }, showCmapStatus); const exportDialog = new CmapExportDialog( $("cmap-export-dialog"), tr, buildCurrentCmapMarkdownExport, buildCurrentCmapJsonExport); const unsavedDialog = new CmapUnsavedDialog($("cmap-unsaved-dialog")); let pendingCmapTransition = null; let cmapAutosaveTimer = null; let cmapSavePromise = null; let cmapEmbedHydrationTimer = null; 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 cmapRepository.load(conceptMap.slug); 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.loadModel(stored.model); 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); } } } function startCmapSlug() { return settingsRepository.startCmapSlug; } async function setStartCmapSlug(slug) { return settingsRepository.setStartCmap(slug); } //////////////////////////////////////////////////////////////////////////////// // 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 hideCmapDescriptionTooltip() { cmapDescriptionPreview.hide(); } 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); await cmapDescriptionPreview.show(button, reference, tr("loading", "Loading…")); } /** * 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 cmapContextCreateContext = {}; 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 titledCmapComboboxEntry(record) { const label = record.title || record.slug; return { value: record.slug, label, description: record.slug }; } function currentConceptDialogResources() { const prototype = cmapPrototypeState(); return { pages: state.pages, conceptMaps: state.conceptMaps, parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot) }; } /** * 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 (conceptDialog) conceptDialog.open(record, currentConceptDialogResources()); } function openNewCmapConceptDialog(context = {}) { if (conceptDialog) { conceptDialog.openNew(context, currentConceptDialogResources()); } } /** Apply validated concept-dialog values as one editor history transaction. */ async function saveCmapConcept(record, createContext, values) { const prototype = cmapPrototypeState(); if ((!record && !createContext) || !prototype.editor) return false; const existingNonPersonTags = record && Array.isArray(record.tags) ? record.tags.filter((tag) => !(tag && typeof tag === "object" && tag.type === "person")) : []; const personTags = values.personNames .map((name) => ({ type: "person", value: name })); const changes = { kind: record && record.kind === "submap" ? "submap" : (values.pageSlug ? "page" : "concept"), label: values.label, synopsis: values.synopsis, aspects: values.aspects, tags: [...existingNonPersonTags, ...personTags], descriptionPageSlug: values.descriptionPageSlug, pageSlug: values.pageSlug, cmapSlug: values.cmapSlug, externalUrl: values.externalUrl, parentCmapLink: values.parentCmapLink, imageSource: values.imageSource, ...values.appearance }; const existingConceptId = sharedCmapConceptId(values.label); if (!record || record.kind !== "submap") { changes.borderColor = values.pageSlug ? "#4479a1" : ((values.cmapSlug || values.parentCmapLink) ? "#57834a" : "#a97c00"); } 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(); if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true }); return true; } function applyCmapZoom(value) { const prototype = cmapPrototypeState(); const percent = Math.max(25, Math.min(300, Number(value) || 100)); $("cmap-zoom-percent").value = String(percent); 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 setCmapZoom(value) { const percent = applyCmapZoom(value); const cmapSlug = state.currentConceptMap?.slug; if (cmapSlug) { settingsRepository.setZoom(cmapSlug, cmapZoomContextKey(), percent) .catch((error) => console.warn("The CMap zoom preference could not be stored.", error)); } return percent; } function setCmapPageGuides(visible) { $("cmap-canvas").classList.toggle("cmap-page-guides-hidden", !visible); $("cmap-toggle-page-guides").setAttribute("aria-checked", String(visible)); } function restoreCmapPageGuides() { setCmapPageGuides(settingsRepository.pageGuidesVisible); } function showCmapStatus(message, temporary = false) { if (temporary) { cmapStatus.showTemporarily(message); } else { cmapStatus.set(message); } } 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 cmapZoomContextKey() { const prototype = cmapPrototypeState(); const activeMap = prototype.editor && prototype.editor.activeMapRoot && prototype.editor.activeMapRoot.mapReference ? prototype.editor.activeMapRoot.mapReference.id : "root"; return activeMap; } function restoreCmapZoom() { const cmapSlug = state.currentConceptMap?.slug; const stored = cmapSlug ? settingsRepository.zoom(cmapSlug, cmapZoomContextKey()) : 100; return applyCmapZoom(stored); } function closeCmapContextMenu() { cmapContextMenu.close(); } 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 = {}) { cmapContextCreateContext = createContext; cmapContextMenu.openAt(clientX, clientY); } 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") || conceptDialog?.isOpen()) 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(cmapModel = 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 (cmapModel) { prototype.editor.loadModel(cmapModel); 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 [conceptMaps, placements] = await Promise.all([ cmapRepository.list(), cmapRepository.conceptUsage().catch((error) => { console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error); return []; }) ]); state.conceptMaps = conceptMaps; state.cmapConceptUsage = new Map(); state.cmapConceptIdsByName = new Map(); state.cmapPageConcepts = new Map(); 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 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 summary = typeof conceptMap.toSummary === "function" ? conceptMap.toSummary() : conceptMap; const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug); if (index >= 0) { state.conceptMaps[index] = { ...state.conceptMaps[index], ...summary }; } else { state.conceptMaps.push(summary); } renderConceptMapSelector(); } async function requestCmapTransition(action) { if (!cmapHasUnsavedChanges()) { await action(); return true; } if (pendingCmapTransition) return false; const transition = (async () => { const choice = await unsavedDialog.choose(); if (choice === "save" && !await saveStoredConceptMap()) return false; if (choice === "discard") markCurrentCmapSaved(); if (choice === "cancel") { renderConceptMapSelector(); return false; } await action(); return true; })(); pendingCmapTransition = transition; try { return await transition; } finally { if (pendingCmapTransition === transition) pendingCmapTransition = null; } } async function openStoredConceptMap(slug) { if (!slug) return; const loadSequence = ++state.cmapLoadSequence; showCmapStatus(tr("loading", "Loading…")); const conceptMap = await cmapRepository.load(slug); if (loadSequence !== state.cmapLoadSequence) return; const derivedView = conceptMap.model.derivedView(); let sourceMap = null; let editorModel = conceptMap.model; if (derivedView && typeof derivedView === "object" && typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug && Number.isInteger(Number(derivedView.rootItemId))) { sourceMap = await cmapRepository.load(derivedView.sourceCmapSlug); if (loadSequence !== state.cmapLoadSequence) return; editorModel = sourceMap.model; } console.info("[racket-wiki:cmap-host 0.2.122] stored CMap received", { slug: conceptMap.slug, version: conceptMap.currentVersion, itemCount: conceptMap.model.conceptMap.items().length, connectorCount: conceptMap.model.conceptMap.connectors().length }); state.currentConceptMap = conceptMap; state.currentConceptMapSource = sourceMap; renderConceptMapSelector(); resetCmapPrototype(editorModel, 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 cmapRepository.loadVersion(conceptMap, version); const currentSnapshot = JSON.stringify(conceptMap.toDocument()); resetCmapPrototype(historical.model, 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 createStoredConceptMap() { const title = window.prompt(tr("concept-map-name", "Concept map name"), ""); if (!title || !title.trim()) return; const model = new CmapModel(); const conceptMap = await cmapRepository.create(title.trim(), model); 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 cmapRepository.load(record.cmapSlug); const derivedView = linkedMap.model.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.model.metadata()); storedMap = await cmapRepository.save( storedMap, extraction.childModel, { summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap"), saveKind: "manual" }); } else { const prepared = editor.prepareStoredSubmapExtraction(record, null); if (!prepared) return false; storedMap = await cmapRepository.create( title.trim(), prepared.childModel); extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug); } editor.replaceModel(extraction.parentModel); 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 cmapRepository.rename(conceptMap, title.trim()); await loadConceptMaps(); showCmapStatus(tr("concept-map-renamed", "CMap renamed"), true); return true; } catch (error) { showCmapStatus(error.message); return false; } } function activeCmapMetadata() { const currentModel = state.currentConceptMap?.model; if (currentModel?.derivedView()) { const metadata = currentModel.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; metadataDialog.open(activeCmapMetadata()); closeCmapContextMenu(); } async function saveCmapMetadata(metadata) { const conceptMap = state.currentConceptMap; const editor = cmapPrototypeState().editor; if (!conceptMap || !editor) return false; if (conceptMap.model.derivedView()) { const updatedModel = conceptMap.model.withMetadata(metadata); const updated = await cmapRepository.save(conceptMap, updatedModel, { summary: tr("updated-concept-map-details", "Updated CMap details"), saveKind: "manual" }); 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; } showCmapStatus(tr("concept-map-details-saved", "CMap details saved"), true); return true; } function openCmapExportDialog() { if (!state.currentConceptMap) return; exportDialog.open(state.currentConceptMap.slug); closeCmapContextMenu(); } async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) { if (!state.currentConceptMap) { 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.")); } return markdownExporter.export(state.currentConceptMap, { maxDepth: depth, includeWikiPages, language: state.language }); } async function buildCurrentCmapJsonExport(depth) { 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.")); } return jsonExporter.export(state.currentConceptMap, depth); } async function importCmapBundleFile(file) { const bundle = await jsonImporter.read(file); 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 conflicts = jsonImporter.conflicts(bundle, state.pages, state.conceptMaps); let replaceExisting = false; if (conflicts.pages.length || conflicts.conceptMaps.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}", conflicts.conceptMaps.length) .replace("{pages}", conflicts.pages.length)); } const result = await jsonImporter.import(bundle, { pages: state.pages, conceptMaps: state.conceptMaps, replaceExisting, summary: tr("imported-from-cmap-json", "Imported from CMap JSON") }); 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 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 cmapRepository.archive(conceptMap, typedTitle); 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 model = prototype.editor.currentModel(); 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 cmapRepository.create(title.trim(), model); const savedRoute = cmapRoute(state.currentConceptMap.slug); history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`); state.cmapGuardHash = savedRoute; await loadConceptMaps(); } else { const savedConceptMap = await cmapRepository.save( conceptMapAtStart, model, { summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")), snapshot: snapshotVersion, saveKind: effectiveHistoryMode }); 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", async () => { if (!state.currentConceptMap) return; try { await setStartCmapSlug(state.currentConceptMap.slug); renderConceptMapSelector(); showCmapStatus(tr("start-concept-map-set", "Start CMap set"), true); closeCmapContextMenu(); } catch (error) { showCmapStatus(error.message); } }); $("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", () => { peopleDialog.open().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", () => { const conceptMap = currentCmapStorageMap(); if (conceptMap) { historyDialog.open(conceptMap).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); settingsRepository.setPageGuidesVisible(visible) .catch((error) => { setCmapPageGuides(settingsRepository.pageGuidesVisible); showCmapStatus(error.message); }); }); $("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 (cmapContextMenu.isOpen()) { 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-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-import-file").addEventListener("change", (event) => { const file = event.currentTarget.files && event.currentTarget.files[0]; if (file) handleCmapImportFile(file); }); document.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "s" && !$("cmap-view").classList.contains("hidden")) { event.preventDefault(); if (can("editor")) { if (pendingCmapTransition) { unsavedDialog.chooseSave(); return; } saveStoredConceptMap() .catch((error) => console.error(error)); } return; } handleCmapKeyboardShortcut(event); }); /** * Load persistent CMap preferences and the data needed by the workspace. */ this.initialize = async () => { const [appearance] = await Promise.all([ appearanceRepository.load(), settingsRepository.load() ]); appearanceEditor = new CmapAppearanceEditor(appearance, appearanceRepository, tr); conceptDialog = new CmapConceptDialog( $("cmap-concept-dialog"), tr, appearanceEditor, peopleDialog, normalizedCmapExternalUrl) .onSave(saveCmapConcept); restoreCmapPageGuides(); await loadConceptMaps(); await peopleDialog.load(); }; this.renderNodeHtml = cmapNodeHtml; this.queueEmbedHydration = queueCmapEmbedHydration; this.loadConceptMaps = loadConceptMaps; this.conceptMapEntry = titledCmapComboboxEntry; this.normalizeExternalUrl = normalizedCmapExternalUrl; this.clearDescriptionPreviews = () => cmapDescriptionPreview.clear(); this.requestTransition = requestCmapTransition; this.show = showCmapPrototype; this.hasUnsavedChanges = cmapHasUnsavedChanges; this.startSlug = startCmapSlug; } }