compleet ontvlochten
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"use strict";
|
||||
|
||||
/** Manage concept dialogs, selection actions and CMap concept page metadata. */
|
||||
export class CmapConceptController {
|
||||
constructor({ state, api, translate, splitPageReference, pageReference,
|
||||
getEditor, getCurrentStorageMap, save, loadPages, getDialog, status,
|
||||
conceptIdForLabel, getNamespace, getPlacementContext }) {
|
||||
this.state = state;
|
||||
this.api = api;
|
||||
this.translate = translate;
|
||||
this.splitPageReference = splitPageReference;
|
||||
this.pageReference = pageReference;
|
||||
this.getEditor = getEditor;
|
||||
this.getCurrentStorageMap = getCurrentStorageMap;
|
||||
this.save = save;
|
||||
this.loadPages = loadPages;
|
||||
this.getDialog = getDialog;
|
||||
this.status = status;
|
||||
this.conceptIdForLabel = conceptIdForLabel;
|
||||
this.getNamespace = getNamespace;
|
||||
this.getPlacementContext = getPlacementContext;
|
||||
}
|
||||
|
||||
addNode(options = {}) {
|
||||
const editor = this.getEditor();
|
||||
if (!editor) return null;
|
||||
const conceptId = options.conceptId || this.conceptIdForLabel(options.label);
|
||||
return editor.addItem(conceptId ? { ...options, conceptId } : options);
|
||||
}
|
||||
|
||||
dialogResources() {
|
||||
const editor = this.getEditor();
|
||||
const namespaceModel = this.state.currentConceptMapSource?.model ||
|
||||
this.state.currentConceptMap?.model;
|
||||
return {
|
||||
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
|
||||
pages: this.state.pages,
|
||||
conceptMaps: this.state.conceptMaps,
|
||||
parentMapAvailable: Boolean(editor && editor.activeMapRoot)
|
||||
};
|
||||
}
|
||||
|
||||
openDialog(record) {
|
||||
this.getDialog()?.open(record, this.dialogResources());
|
||||
}
|
||||
|
||||
openNewDialog(context = {}) {
|
||||
this.getDialog()?.openNew(context, this.dialogResources());
|
||||
}
|
||||
|
||||
async saveConcept(record, createContext, values) {
|
||||
const editor = this.getEditor();
|
||||
if ((!record && !createContext) || !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 = this.conceptIdForLabel(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;
|
||||
editor.refreshConceptUsageIndicators([previousConceptId, existingConceptId].filter(Boolean));
|
||||
}
|
||||
editor.updateItem(record, changes);
|
||||
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 = this.addNode(changes);
|
||||
if (createContext.source && newRecord) editor.finishRelation(createContext.source, newRecord);
|
||||
else if (newRecord) editor.selectItem(newRecord);
|
||||
}
|
||||
editor.commitHistory();
|
||||
if (this.getCurrentStorageMap()) await this.save({ automatic: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
updateSelectionToolbar(editor, selectedRecords = [], connector = null) {
|
||||
const toolbar = document.getElementById("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();
|
||||
document.getElementById("cmap-move-selected-to-namespace").disabled = !editor ||
|
||||
!this.getNamespace() || !selected.some((item) => item.kind !== "phrase");
|
||||
}
|
||||
|
||||
pageMatchesReference(page, reference) {
|
||||
if (page.slug === reference) return true;
|
||||
const parts = this.splitPageReference(reference);
|
||||
return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() &&
|
||||
String(page.pageSlug || this.splitPageReference(page.slug).slug).toLocaleLowerCase() ===
|
||||
parts.slug.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
async moveSelectedDescriptionsToNamespace() {
|
||||
const editor = this.getEditor();
|
||||
const namespace = this.getNamespace();
|
||||
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 = this.splitPageReference(current);
|
||||
const target = this.pageReference(namespace, parts.slug);
|
||||
if (target === current) continue;
|
||||
const existing = this.state.pages.find((page) => this.pageMatchesReference(page, current));
|
||||
const conflict = this.state.pages.find((page) => this.pageMatchesReference(page, target));
|
||||
if (existing && conflict && existing !== conflict) {
|
||||
this.status(this.translate("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 this.api(`/api/pages/${encodeURIComponent(change.current)}/rename`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: change.existing.title,
|
||||
namespace,
|
||||
slug: this.splitPageReference(change.current).slug,
|
||||
summary: this.translate("move-description-page-summary", "Moved description page to CMap namespace")
|
||||
})
|
||||
});
|
||||
change.target = page.slug;
|
||||
}
|
||||
editor.updateItem(change.record, { descriptionPageSlug: change.target });
|
||||
}
|
||||
await this.loadPages();
|
||||
editor.commitHistory();
|
||||
if (this.getCurrentStorageMap()) await this.save({ automatic: true });
|
||||
this.status(this.translate("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.")
|
||||
.replace("{namespace}", namespace), true);
|
||||
return true;
|
||||
}
|
||||
|
||||
placementOptions() {
|
||||
const context = this.getPlacementContext() || {};
|
||||
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;
|
||||
}
|
||||
|
||||
editSelected(record = null) {
|
||||
const editor = this.getEditor();
|
||||
const selectedRecord = record || editor?.selected();
|
||||
if (!selectedRecord) {
|
||||
window.alert(this.translate("select-one-concept", "Select one concept first."));
|
||||
return;
|
||||
}
|
||||
if (selectedRecord.kind === "phrase") {
|
||||
editor.editPhraseInline(selectedRecord);
|
||||
return;
|
||||
}
|
||||
this.openDialog(selectedRecord);
|
||||
}
|
||||
|
||||
groupSelection() {
|
||||
const editor = this.getEditor();
|
||||
if (!editor || !editor.canGroupSelection()) return false;
|
||||
const label = window.prompt(
|
||||
this.translate("group-submap-name", "Name of the main concept for the new sub-CMap"),
|
||||
this.translate("sub-concept-map", "Sub concept map"));
|
||||
if (!label || !label.trim()) return false;
|
||||
return editor.groupSelection({
|
||||
label: label.trim(),
|
||||
conceptId: this.conceptIdForLabel(label),
|
||||
childMap: label.trim(),
|
||||
synopsis: this.translate("grouped-submap-synopsis", "Grouped sub-concept map.")
|
||||
});
|
||||
}
|
||||
|
||||
populateSubmap(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export class CmapEditorUiController {
|
||||
this.bind("#cmap-group-selected", "click", () => this.actions.group?.());
|
||||
this.bind("#cmap-ungroup-selected", "click", (_event, editor) => editor?.ungroupSelection());
|
||||
this.bind("#cmap-hide-selected", "click", (_event, editor) => editor?.hideSelectionInCurrentContext());
|
||||
this.bind("#cmap-select-all", "click", (_event, editor) => editor?.selectAll());
|
||||
this.bind("#cmap-zoom-out", "click", () => this.actions.zoom?.(-10));
|
||||
this.bind("#cmap-zoom-in", "click", () => this.actions.zoom?.(10));
|
||||
this.bind("#cmap-zoom-reset", "click", () => this.actions.zoom?.(100, true));
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"use strict";
|
||||
|
||||
import { CmapModel } from "../../cmap/model/concept-map.js";
|
||||
|
||||
/** Manage loading, persistence workflows and metadata of stored concept maps. */
|
||||
export class CmapMapController {
|
||||
constructor({ state, repository, referenceIndex, canonicalPageReference,
|
||||
normalizeConceptName, renderSelector, getEditor, resetEditor,
|
||||
getCurrentStorageMap, save, markSaved, cancelAutosave, hasUnsavedChanges,
|
||||
status, translate, cmapRoute, navigateToHash, navigateToMap, refreshPageConnections }) {
|
||||
this.state = state;
|
||||
this.repository = repository;
|
||||
this.referenceIndex = referenceIndex;
|
||||
this.canonicalPageReference = canonicalPageReference;
|
||||
this.normalizeConceptName = normalizeConceptName;
|
||||
this.renderSelector = renderSelector;
|
||||
this.getEditor = getEditor;
|
||||
this.resetEditor = resetEditor;
|
||||
this.getCurrentStorageMap = getCurrentStorageMap;
|
||||
this.save = save;
|
||||
this.markSaved = markSaved;
|
||||
this.cancelAutosave = cancelAutosave;
|
||||
this.hasUnsavedChanges = hasUnsavedChanges;
|
||||
this.status = status;
|
||||
this.translate = translate;
|
||||
this.cmapRoute = cmapRoute;
|
||||
this.navigateToHash = navigateToHash;
|
||||
this.navigateToMap = navigateToMap;
|
||||
this.refreshPageConnections = refreshPageConnections;
|
||||
}
|
||||
|
||||
async loadMaps() {
|
||||
const [conceptMaps, placements] = await Promise.all([
|
||||
this.repository.list(),
|
||||
this.repository.conceptUsage().catch((error) => {
|
||||
console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error);
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
this.state.conceptMaps = conceptMaps;
|
||||
this.referenceIndex.load(
|
||||
placements,
|
||||
conceptMaps,
|
||||
this.canonicalPageReference,
|
||||
this.normalizeConceptName);
|
||||
this.state.cmapConceptUsage = this.referenceIndex.byConceptId;
|
||||
this.state.cmapConceptIdsByName = this.referenceIndex.byName;
|
||||
this.state.cmapPageConcepts = this.referenceIndex.byPage;
|
||||
this.renderSelector();
|
||||
const editor = this.getEditor();
|
||||
if (editor) editor.refreshConceptUsageIndicators();
|
||||
if (this.state.currentPage) this.refreshPageConnections(this.state.currentPage);
|
||||
return this.state.conceptMaps;
|
||||
}
|
||||
|
||||
updateSummary(conceptMap) {
|
||||
const summary = typeof conceptMap.toSummary === "function" ?
|
||||
conceptMap.toSummary() : conceptMap;
|
||||
const index = this.state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
|
||||
if (index >= 0) {
|
||||
this.state.conceptMaps[index] = { ...this.state.conceptMaps[index], ...summary };
|
||||
} else {
|
||||
this.state.conceptMaps.push(summary);
|
||||
}
|
||||
this.renderSelector();
|
||||
}
|
||||
|
||||
async open(slug) {
|
||||
if (!slug) return;
|
||||
const loadSequence = ++this.state.cmapLoadSequence;
|
||||
this.status(this.translate("loading", "Loading…"));
|
||||
const conceptMap = await this.repository.load(slug);
|
||||
if (loadSequence !== this.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 this.repository.load(derivedView.sourceCmapSlug);
|
||||
if (loadSequence !== this.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
|
||||
});
|
||||
this.state.currentConceptMap = conceptMap;
|
||||
this.state.currentConceptMapSource = sourceMap;
|
||||
this.renderSelector();
|
||||
this.resetEditor(editorModel, false);
|
||||
if (sourceMap) this.openDerivedView(conceptMap, derivedView);
|
||||
this.markSaved();
|
||||
const editor = this.getEditor();
|
||||
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", {
|
||||
slug: conceptMap.slug,
|
||||
editorAvailable: Boolean(editor),
|
||||
itemCount: editor ? editor.itemCount() : 0,
|
||||
connectorCount: editor ? editor.connectorCount() : 0
|
||||
});
|
||||
this.status(this.translate("concept-map-loaded", "CMap loaded"), true);
|
||||
}
|
||||
|
||||
openDerivedView(conceptMap, derivedView) {
|
||||
const editor = this.getEditor();
|
||||
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);
|
||||
}
|
||||
|
||||
async loadHistoricalVersion(version) {
|
||||
const conceptMap = this.getCurrentStorageMap();
|
||||
if (!conceptMap) return;
|
||||
const historical = await this.repository.loadVersion(conceptMap, version);
|
||||
const currentSnapshot = JSON.stringify(conceptMap.toDocument());
|
||||
this.resetEditor(historical.model, false);
|
||||
this.state.cmapSavedSnapshot = currentSnapshot;
|
||||
this.status(
|
||||
this.translate("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
|
||||
.replace("{version}", String(historical.version)),
|
||||
true);
|
||||
}
|
||||
|
||||
async create() {
|
||||
const title = window.prompt(this.translate("concept-map-name", "Concept map name"), "");
|
||||
if (!title || !title.trim()) return;
|
||||
const conceptMap = await this.repository.create(title.trim(), new CmapModel());
|
||||
await this.loadMaps();
|
||||
location.hash = this.cmapRoute(conceptMap.slug);
|
||||
}
|
||||
|
||||
async promoteSelectedSubmap() {
|
||||
const editor = this.getEditor();
|
||||
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) {
|
||||
this.status(this.translate("empty-submap", "This sub-CMap has no concepts to move."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceMap = this.getCurrentStorageMap();
|
||||
let linkedMap = null;
|
||||
let title = null;
|
||||
if (record.cmapSlug) {
|
||||
linkedMap = this.state.currentConceptMap?.slug === record.cmapSlug ?
|
||||
this.state.currentConceptMap : await this.repository.load(record.cmapSlug);
|
||||
const derivedView = linkedMap.model.derivedView();
|
||||
const matchesSource = sourceMap && derivedView &&
|
||||
derivedView.sourceCmapSlug === sourceMap.slug &&
|
||||
Number(derivedView.rootItemId) === Number(record.id);
|
||||
if (!matchesSource) {
|
||||
this.status(this.translate("submap-already-independent",
|
||||
"This linked CMap is already independent."), true);
|
||||
await this.navigateToMap(record.cmapSlug);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
title = window.prompt(
|
||||
this.translate("submap-name", "Name of the new concept map"),
|
||||
record.childMap || record.label);
|
||||
if (!title || !title.trim()) return false;
|
||||
}
|
||||
|
||||
const buttons = [document.getElementById("cmap-promote-submap"),
|
||||
document.getElementById("cmap-extract-selected")];
|
||||
for (const button of buttons) button.disabled = true;
|
||||
try {
|
||||
const sourceSaved = await this.save({
|
||||
force: true,
|
||||
snapshotVersion: true,
|
||||
summary: this.translate("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 this.repository.save(storedMap, extraction.childModel, {
|
||||
summary: this.translate("submap-extracted", "Sub-CMap moved to a separate CMap"),
|
||||
saveKind: "manual"
|
||||
});
|
||||
} else {
|
||||
const prepared = editor.prepareStoredSubmapExtraction(record, null);
|
||||
if (!prepared) return false;
|
||||
storedMap = await this.repository.create(title.trim(), prepared.childModel);
|
||||
extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug);
|
||||
}
|
||||
|
||||
editor.replaceModel(extraction.parentModel);
|
||||
const parentSaved = await this.save({
|
||||
force: true,
|
||||
historyMode: "autosave",
|
||||
summary: this.translate("submap-extracted", "Sub-CMap moved to a separate CMap")
|
||||
});
|
||||
if (!parentSaved) {
|
||||
this.status(this.translate("submap-created-parent-unsaved",
|
||||
"The new CMap was created, but the parent CMap still needs to be saved."));
|
||||
return false;
|
||||
}
|
||||
await this.navigateToMap(storedMap.slug);
|
||||
return true;
|
||||
} finally {
|
||||
const selected = editor.selected();
|
||||
const canExtractSubmap = selected && selected.kind === "submap";
|
||||
for (const button of buttons) button.disabled = !canExtractSubmap;
|
||||
document.getElementById("cmap-extract-selected")
|
||||
.classList.toggle("hidden", !canExtractSubmap);
|
||||
}
|
||||
}
|
||||
|
||||
async rename() {
|
||||
this.cancelAutosave();
|
||||
if (this.hasUnsavedChanges() && !await this.save({ automatic: true })) return false;
|
||||
const conceptMap = this.state.currentConceptMap;
|
||||
if (!conceptMap) return false;
|
||||
const title = window.prompt(
|
||||
this.translate("rename-concept-map", "Rename CMap"), conceptMap.title);
|
||||
if (!title || !title.trim() || title.trim() === conceptMap.title) return false;
|
||||
try {
|
||||
this.state.currentConceptMap = await this.repository.rename(conceptMap, title.trim());
|
||||
await this.loadMaps();
|
||||
this.status(this.translate("concept-map-renamed", "CMap renamed"), true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.status(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
activeMetadata() {
|
||||
const currentModel = this.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 = this.getEditor();
|
||||
return editor?.getDocumentMetadata ? editor.getDocumentMetadata() :
|
||||
{ tags: [], summary: "", explanationPageSlug: "" };
|
||||
}
|
||||
|
||||
async saveMetadata(metadata) {
|
||||
const conceptMap = this.state.currentConceptMap;
|
||||
const editor = this.getEditor();
|
||||
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 this.repository.save(conceptMap, updatedModel, {
|
||||
summary: this.translate("updated-concept-map-details", "Updated CMap details"),
|
||||
saveKind: "manual"
|
||||
});
|
||||
this.state.currentConceptMap = updated;
|
||||
this.updateSummary(updated);
|
||||
} else {
|
||||
editor.setDocumentMetadata(metadata);
|
||||
if (!await this.save({
|
||||
force: true,
|
||||
summary: this.translate("updated-concept-map-details", "Updated CMap details")
|
||||
})) return false;
|
||||
}
|
||||
this.status(this.translate("concept-map-details-saved", "CMap details saved"), true);
|
||||
return true;
|
||||
}
|
||||
|
||||
async archive() {
|
||||
this.cancelAutosave();
|
||||
const conceptMap = this.state.currentConceptMap;
|
||||
if (!conceptMap) return false;
|
||||
const question = this.translate(
|
||||
"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(
|
||||
this.translate("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) {
|
||||
this.status(this.translate("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived."));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.repository.archive(conceptMap, typedTitle);
|
||||
this.markSaved();
|
||||
this.state.currentConceptMap = null;
|
||||
this.state.currentConceptMapSource = null;
|
||||
await this.loadMaps();
|
||||
const target = this.state.conceptMaps.length ?
|
||||
this.cmapRoute(this.state.conceptMaps[0].slug) : "#cmaps";
|
||||
await this.navigateToHash(target);
|
||||
this.status(this.translate("concept-map-archived", "CMap archived"), true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.status(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createSnapshot() {
|
||||
if (!this.getCurrentStorageMap()) return false;
|
||||
const description = window.prompt(this.translate("snapshot-description", "Snapshot description"), "");
|
||||
if (description === null) return false;
|
||||
return this.save({
|
||||
force: true,
|
||||
summary: description.trim() || this.translate("snapshot", "Snapshot"),
|
||||
snapshotVersion: true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user