Files
racket-wiki/static/js/wiki/cmap/cmap-concept-controller.js
T
2026-09-03 17:13:22 +02:00

237 lines
9.7 KiB
JavaScript

"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);
}
}
}