compleet ontvlochten

This commit is contained in:
2026-09-03 17:13:22 +02:00
parent 3684c5ddbf
commit e971dfe942
4 changed files with 634 additions and 484 deletions
+61 -483
View File
@@ -5,7 +5,6 @@ import {
} 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";
@@ -30,6 +29,8 @@ 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";
@@ -199,6 +200,43 @@ export class CmapWorkspaceController {
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;
@@ -255,10 +293,7 @@ export class CmapWorkspaceController {
}
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);
return conceptController.addNode(options);
}
function normalizeCmapConceptName(label) {
@@ -315,15 +350,7 @@ export class CmapWorkspaceController {
}
function currentConceptDialogResources() {
const prototype = cmapPrototypeState();
const namespaceModel = state.currentConceptMapSource?.model ||
state.currentConceptMap?.model;
return {
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
pages: state.pages,
conceptMaps: state.conceptMaps,
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
};
return conceptController.dialogResources();
}
/**
@@ -332,74 +359,16 @@ export class CmapWorkspaceController {
* post : A modal form shows concept text, presentation and image fields.
*/
function openCmapConceptDialog(record) {
if (conceptDialog) conceptDialog.open(record, currentConceptDialogResources());
return conceptController.openDialog(record);
}
function openNewCmapConceptDialog(context = {}) {
if (conceptDialog) {
conceptDialog.openNew(context, currentConceptDialogResources());
}
return conceptController.openNewDialog(context);
}
/** 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;
return conceptController.saveConcept(record, createContext, values);
}
function applyCmapZoom(value) {
@@ -480,23 +449,7 @@ export class CmapWorkspaceController {
}
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();
const namespaceButton = $("cmap-move-selected-to-namespace");
namespaceButton.disabled = !editor || !diagramNamespace() ||
!selected.some((item) => item.kind !== "phrase");
return conceptController.updateSelectionToolbar(editor, selectedRecords, connector);
}
function diagramNamespace() {
@@ -504,58 +457,8 @@ export class CmapWorkspaceController {
return model?.metadata()?.namespace || "";
}
function pageMatchesReference(page, reference) {
if (page.slug === reference) return true;
const parts = splitPageReference(reference);
return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() &&
String(page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase() ===
parts.slug.toLocaleLowerCase();
}
async function moveSelectedDescriptionsToNamespace() {
const editor = cmapPrototypeState().editor;
const namespace = diagramNamespace();
if (!editor || !namespace) return false;
const records = editor.selectedAll().filter((record) => record.kind !== "phrase");
if (!records.length) return false;
const changes = [];
for (const record of records) {
const current = record.descriptionPageSlug;
if (!current) continue;
const parts = splitPageReference(current);
const target = pageReference(namespace, parts.slug);
if (target === current) continue;
const existing = state.pages.find((page) => pageMatchesReference(page, current));
const conflict = state.pages.find((page) => pageMatchesReference(page, target));
if (existing && conflict && existing !== conflict) {
showCmapStatus(tr("namespace-move-conflict", "Cannot move {page}: the target page already exists.")
.replace("{page}", target));
return false;
}
changes.push({ record, current, target, existing });
}
if (!changes.length) return false;
for (const change of changes) {
if (change.existing) {
const page = await api(`/api/pages/${encodeURIComponent(change.current)}/rename`, {
method: "POST",
body: JSON.stringify({
title: change.existing.title,
namespace,
slug: splitPageReference(change.current).slug,
summary: tr("move-description-page-summary", "Moved description page to CMap namespace")
})
});
change.target = page.slug;
}
editor.updateItem(change.record, { descriptionPageSlug: change.target });
}
await loadPages();
editor.commitHistory();
if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true });
showCmapStatus(tr("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.")
.replace("{namespace}", namespace), true);
return true;
return conceptController.moveSelectedDescriptionsToNamespace();
}
function openCmapContextMenu(clientX, clientY, createContext = {}) {
@@ -564,17 +467,7 @@ export class CmapWorkspaceController {
}
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;
return conceptController.placementOptions();
}
/**
@@ -583,17 +476,7 @@ export class CmapWorkspaceController {
* 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);
return conceptController.editSelected(record);
}
function cmapKeyboardEditingTarget(target) {
@@ -684,46 +567,11 @@ export class CmapWorkspaceController {
}
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.")
});
return conceptController.groupSelection();
}
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);
}
return conceptController.populateSubmap(record, editor);
}
/**
@@ -945,27 +793,7 @@ export class CmapWorkspaceController {
}
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;
cmapReferenceIndex.load(
placements,
conceptMaps,
canonicalPageReference,
normalizeCmapConceptName);
state.cmapConceptUsage = cmapReferenceIndex.byConceptId;
state.cmapConceptIdsByName = cmapReferenceIndex.byName;
state.cmapPageConcepts = cmapReferenceIndex.byPage;
renderConceptMapSelector();
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
if (state.currentPage) renderPageCmapConnections(state.currentPage);
return state.conceptMaps;
return mapController.loadMaps();
}
function currentCmapSnapshot() {
@@ -1005,15 +833,7 @@ export class CmapWorkspaceController {
}
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();
return mapController.updateSummary(conceptMap);
}
async function requestCmapTransition(action) {
@@ -1021,204 +841,27 @@ export class CmapWorkspaceController {
}
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);
return mapController.open(slug);
}
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);
return mapController.loadHistoricalVersion(version);
}
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);
return mapController.create();
}
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);
}
return mapController.promoteSelectedSubmap();
}
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;
}
return mapController.rename();
}
function activeCmapMetadata() {
const currentModel = state.currentConceptMap?.model;
if (currentModel?.derivedView()) {
const metadata = currentModel.metadata();
return {
namespace: metadata.namespace || "",
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: "" };
return mapController.activeMetadata();
}
function openCmapMetadataDialog() {
@@ -1228,34 +871,7 @@ export class CmapWorkspaceController {
}
async function saveCmapMetadata(metadata) {
const conceptMap = state.currentConceptMap;
const editor = cmapPrototypeState().editor;
if (!conceptMap || !editor) return false;
const legacyExplanation = `cmap:${conceptMap.slug}`;
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
metadata = {
...metadata,
explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}`
};
}
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;
return mapController.saveMetadata(metadata);
}
function openCmapExportDialog() {
@@ -1301,38 +917,7 @@ export class CmapWorkspaceController {
async function deleteStoredConceptMap() {
cancelCmapAutosave();
await storage.waitForSave();
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;
}
return mapController.archive();
}
async function saveStoredConceptMap({ automatic = false, force = false,
@@ -1342,14 +927,7 @@ export class CmapWorkspaceController {
}
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
});
return mapController.createSnapshot();
}
async function showCmapPrototype(requestedSlug = null) {