import { newPageReference, pageReference, splitPageReference } from "../reference.js"; import { cmapRoute, pageRoute } from "../routes.js"; import { cmapMentionTarget, escapeHtml } from "../markdown.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 { CmapReferenceIndex } from "../../cmap/model/cmap-reference-index.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"; import { CmapStorageController } from "./cmap-storage-controller.js"; import { CmapNavigationController } from "./cmap-navigation-controller.js"; import { CmapTransferController } from "./cmap-transfer-controller.js"; import { CmapEditorUiController } from "./cmap-editor-ui-controller.js"; import { CmapEditorHost } from "./cmap-editor-host.js"; import { CmapMapController } from "./cmap-map-controller.js"; import { CmapConceptController } from "./cmap-concept-controller.js"; import { CmapEmbedView } from "./cmap-embed-view.js"; import { CmapEditorPresentation } from "./cmap-editor-presentation.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 CmapWorkspaceController { 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 cmapReferenceIndex = new CmapReferenceIndex(); const presentation = new CmapEditorPresentation({ state, translate: tr, canonicalPageReference, referenceIndex: cmapReferenceIndex }); const embedView = new CmapEmbedView({ repository: cmapRepository, conceptMaps: () => state.conceptMaps, resolveReference: cmapMentionTarget, route: cmapRoute, translate: tr }); 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")); const storage = new CmapStorageController({ repository: cmapRepository, getEditor: () => cmapPrototypeState().editor, getCurrentMap: currentCmapStorageMap, getViewVisible: () => !$("cmap-view").classList.contains("hidden"), renderSvg: currentCmapRenderedSvg, reloadMaps: loadConceptMaps, status: showCmapStatus, translate: tr, unsavedDialog, canEdit: () => can("editor"), onMapSaved: (saved) => { if (!state.currentConceptMap && !state.currentConceptMapSource) { state.currentConceptMap = saved; const savedRoute = cmapRoute(saved.slug); history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`); state.cmapGuardHash = savedRoute; } else if (state.currentConceptMapSource?.slug === saved.slug) { state.currentConceptMapSource = saved; } else if (state.currentConceptMap?.slug === saved.slug) { state.currentConceptMap = saved; } updateStoredConceptMapSummary(saved); } }); const navigation = new CmapNavigationController({ navigateToHash, cmapRoute, getEditor: () => cmapPrototypeState().editor, getCurrentMap: currentCmapStorageMap, getParentMap: () => state.currentConceptMapSource, storage }); const transfer = new CmapTransferController({ repository: cmapRepository, jsonImporter, jsonExporter, markdownExporter, getCurrentMap: () => state.currentConceptMap, getPages: () => state.pages, getConceptMaps: () => state.conceptMaps, loadPages, loadConceptMaps, navigateToMap: (slug) => navigation.open(slug), saveBeforeTransfer: () => cmapHasUnsavedChanges() ? saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }) : true, confirm: (message) => window.confirm(message), status: showCmapStatus, translate: tr }); const editorUi = new CmapEditorUiController({ root: document, getEditor: () => cmapPrototypeState().editor, actions: { save: () => saveStoredConceptMap(), edit: () => editSelectedCmapNode(), group: () => groupSelectedCmapItems(), zoom: (value, absolute = false) => setCmapZoom( absolute ? Number(value) : Number($("cmap-zoom-percent").value) + value), togglePageGuides: () => { const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true"; setCmapPageGuides(visible); settingsRepository.setPageGuidesVisible(visible).catch((error) => showCmapStatus(error.message)); }, selection: (action, editor) => { if (action === "edit") editSelectedCmapNode(); if (action === "group") groupSelectedCmapItems(); if (action === "ungroup") editor.ungroupSelection(); if (action === "hide") editor.hideSelectionInCurrentContext(); } } }); const editorHost = new CmapEditorHost({ canvas: $("cmap-canvas"), factory: (canvas, options) => window.RacketWikiCmap.createEditor(canvas, options), createOptions: () => ({}) }); const mapController = new CmapMapController({ state, repository: cmapRepository, referenceIndex: cmapReferenceIndex, canonicalPageReference, normalizeConceptName: normalizeCmapConceptName, renderSelector: renderConceptMapSelector, getEditor: () => cmapPrototypeState().editor, resetEditor: resetCmapPrototype, getCurrentStorageMap: currentCmapStorageMap, save: saveStoredConceptMap, markSaved: markCurrentCmapSaved, cancelAutosave: cancelCmapAutosave, hasUnsavedChanges: cmapHasUnsavedChanges, status: showCmapStatus, translate: tr, cmapRoute, navigateToHash, navigateToMap: (slug) => navigateToHash(cmapRoute(slug)), refreshPageConnections: renderPageCmapConnections }); const conceptController = new CmapConceptController({ state, api, translate: tr, splitPageReference, pageReference, getEditor: () => cmapPrototypeState().editor, getCurrentStorageMap: currentCmapStorageMap, save: saveStoredConceptMap, loadPages, getDialog: () => conceptDialog, status: showCmapStatus, conceptIdForLabel: sharedCmapConceptId, getNamespace: diagramNamespace, getPlacementContext: () => cmapContextCreateContext }); 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; embedView.render(document).catch((error) => console.error(error)); }, 0); } 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; } function cmapNodeHtml(record) { return presentation.renderItem(record); } function effectiveCmapConceptUsage(record) { return presentation.conceptUsage(record); } 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; } function showCmapDescriptionTooltip(button) { const item = button.closest("[data-rw-cmap-item-id]"); const editor = cmapPrototypeState().editor; const record = item && editor ? editor.itemRecord(Number(item.dataset.rwCmapItemId)) : null; if (record?.descriptionPageSlug) { cmapDescriptionPreview.show(button, record.descriptionPageSlug, tr("loading", "Loading…")); } } function hideCmapDescriptionTooltip() { cmapDescriptionPreview.hide(); } function addCmapPrototypeNode(options = {}) { return conceptController.addNode(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 openLinkedCmap(record) { return navigation.openLinked(record); } function rememberActiveCmapContext() { return navigation.rememberContext(); } async function restoreActiveCmapContext(slug) { return navigation.restoreContext(slug); } function openParentCmap() { return navigation.openParent(); } function titledCmapComboboxEntry(record) { const label = record.title || record.slug; return { value: record.slug, label, description: record.slug }; } function currentConceptDialogResources() { return conceptController.dialogResources(); } /** * 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) { return conceptController.openDialog(record); } function openNewCmapConceptDialog(context = {}) { return conceptController.openNewDialog(context); } /** Apply validated concept-dialog values as one editor history transaction. */ async function saveCmapConcept(record, createContext, values) { return conceptController.saveConcept(record, createContext, values); } 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) { return conceptController.updateSelectionToolbar(editor, selectedRecords, connector); } function diagramNamespace() { const model = state.currentConceptMapSource?.model || state.currentConceptMap?.model; return model?.metadata()?.namespace || ""; } async function moveSelectedDescriptionsToNamespace() { return conceptController.moveSelectedDescriptionsToNamespace(); } function openCmapContextMenu(clientX, clientY, createContext = {}) { cmapContextCreateContext = createContext; cmapContextMenu.openAt(clientX, clientY); } function cmapPlacementOptions() { return conceptController.placementOptions(); } /** * 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) { return conceptController.editSelected(record); } 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() { return conceptController.groupSelection(); } function populateCmapPrototypeSubmap(record, editor) { return conceptController.populateSubmap(record, editor); } /** * 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 = ""; editorHost.destroy(); canvas.replaceChildren(); state.cmapPrototype = null; updateCmapSelectionToolbar(null, [], null); console.info("[racket-wiki:cmap-host 0.2.122] resetCmapPrototype", { 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("/js/cmap/cmap.css")) || null }); if (!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 = editorHost.create({ renderItem: (record) => cmapNodeHtml(record), boundaryReferenceMapTitle: state.currentConceptMapSource?.title || state.currentConceptMap?.title || "", onOpenPage: (record) => { if (record.pageSlug) { const namespace = state.currentConceptMapSource?.model?.metadata()?.namespace || state.currentConceptMap?.model?.metadata()?.namespace || ""; const isDescriptionReference = record.descriptionPageSlug === record.pageSlug; const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null; const existingPage = state.pages.some((pageRecord) => pageRecord.slug === record.pageSlug); const target = !existingPage && namespace && page?.namespace === "cmap" ? pageReference(namespace, page.slug) : record.pageSlug; if (isDescriptionReference && target !== record.pageSlug) { record.pageSlug = target; } if (!state.pages.some((pageRecord) => pageRecord.slug === target)) { state.newPageSuggestedTitle = record.label || ""; } navigateToHash(pageRoute(target)).catch((error) => console.error(error)); } }, onOpenCmap: (record) => { openLinkedCmap(record); }, onOpenParentCmap: () => { openParentCmap(); }, onOpenExternalUrl: (record) => openCmapExternalUrl(record), onOpenStoredSubMap: (record) => { if (record.cmapSlug) { navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error)); } }, onOpenBoundaryReference: () => openParentCmap(), 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() { return mapController.loadMaps(); } function currentCmapSnapshot() { return storage.currentSnapshot(); } function currentCmapRenderedSvg() { const editor = cmapPrototypeState().editor; if (!editor || typeof editor.snapshotSvg !== "function") return ""; try { return editor.snapshotSvg(); } catch (error) { console.warn("The CMap SVG snapshot could not be created.", error); return ""; } } function currentCmapStorageMap() { return state.currentConceptMapSource || state.currentConceptMap; } function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) { storage.markSaved(snapshot); state.cmapSavedSnapshot = snapshot; } function cmapHasUnsavedChanges() { return storage.hasUnsavedChanges(); } function cancelCmapAutosave() { storage.cancelAutosave(); } function scheduleCmapAutosave() { storage.scheduleAutosave(); } function updateStoredConceptMapSummary(conceptMap) { return mapController.updateSummary(conceptMap); } async function requestCmapTransition(action) { return navigation.requestTransition(action); } async function openStoredConceptMap(slug) { return mapController.open(slug); } async function loadHistoricalConceptMapVersion(version) { return mapController.loadHistoricalVersion(version); } async function createStoredConceptMap() { return mapController.create(); } async function promoteSelectedSubmapToStoredMap() { return mapController.promoteSelectedSubmap(); } async function renameStoredConceptMap() { return mapController.rename(); } function activeCmapMetadata() { return mapController.activeMetadata(); } function openCmapMetadataDialog() { if (!state.currentConceptMap) return; metadataDialog.open(activeCmapMetadata()); closeCmapContextMenu(); } async function saveCmapMetadata(metadata) { return mapController.saveMetadata(metadata); } function openCmapExportDialog() { if (!state.currentConceptMap) return; exportDialog.open(state.currentConceptMap.slug); closeCmapContextMenu(); } async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) { return transfer.exportMarkdown({ maxDepth: depth, includeWikiPages, language: state.language }); } async function buildCurrentCmapJsonExport(depth) { return transfer.exportJson(depth); } async function importCmapBundleFile(file) { return transfer.importFile(file); } 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(); await storage.waitForSave(); return mapController.archive(); } async function saveStoredConceptMap({ automatic = false, force = false, summary = null, snapshotVersion = false, historyMode = null } = {}) { return storage.save({ automatic, force, summary, snapshotVersion, historyMode }); } async function createConceptMapSnapshot() { return mapController.createSnapshot(); } 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); if (await restoreActiveCmapContext(requestedSlug)) markCurrentCmapSaved(); } 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-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-move-selected-to-namespace").addEventListener("click", () => { moveSelectedDescriptionsToNamespace() .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-cut-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.cutSelectionReferences(); }); $("cmap-reset").addEventListener("click", () => { if (state.currentConceptMap) { requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug)) .catch((error) => console.error(error)); } else { resetCmapPrototype(); markCurrentCmapSaved(); } }); $("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-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 (storage.hasPendingTransition()) { unsavedDialog.chooseSave(); return; } saveStoredConceptMap() .catch((error) => console.error(error)); } return; } handleCmapKeyboardShortcut(event); }); editorUi.initialize(); /** * 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.navigation = navigation; this.transfer = transfer; this.storage = storage; this.show = showCmapPrototype; this.hasUnsavedChanges = cmapHasUnsavedChanges; this.startSlug = startCmapSlug; } }