254 lines
11 KiB
JavaScript
254 lines
11 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());
|
|
}
|
|
|
|
/**
|
|
* goal : Apply one validated concept-dialog submission as one editor history step.
|
|
* pre : record identifies an existing placement, or createContext describes a new placement.
|
|
* post : Shared concept content and placement presentation are updated and autosave is scheduled.
|
|
* result : False when the editor or required create context is unavailable.
|
|
*/
|
|
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, Boolean(createContext.direct));
|
|
} else if (newRecord) editor.selectItem(newRecord);
|
|
}
|
|
editor.commitHistory();
|
|
if (this.getCurrentStorageMap()) await this.save({ automatic: true });
|
|
return true;
|
|
}
|
|
|
|
/** Update selection-dependent layout and concept action availability in the toolbar. */
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* goal : Move selected concept description pages into the active CMap namespace.
|
|
* pre : An active CMap namespace exists and selected records may have description references.
|
|
* post : Existing pages are renamed, editor references are changed and the map is autosaved.
|
|
* result : False when no applicable selection exists or a target-page conflict is found.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/** Open inline phrase editing or the concept dialog for the selected editor record. */
|
|
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);
|
|
}
|
|
|
|
/** Group the current editor selection into a named inline submap. */
|
|
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.")
|
|
});
|
|
}
|
|
|
|
/** Populate a newly opened sample submap with its initial child records. */
|
|
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);
|
|
}
|
|
}
|
|
}
|