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