/** * Coordinates submap membership, navigation and visibility for the wiki editor. * * The editor remains responsible for item storage and drawing. This controller * owns the rules for the active map context and delegates rendering and model * synchronization through the supplied editor instance. */ export class CmapSubmapController { /** * goal : Create the controller for one wiki CMap editor. * pre : editor owns the items, view, model and editor callbacks. * post : Submap operations can delegate rendering and model work to editor. * result : A CmapSubmapController instance. * internals : The controller keeps no duplicate item state; its accessors * read the editor's active context and map history when an operation runs. * * @param {object} editor The editor facade that owns items and rendering. */ constructor(editor) { this.editor = editor; this.diagramGroups = new Map(); } get items() { return this.editor.items; } get activeMapRoot() { return this.editor.activeMapRoot; } set activeMapRoot(value) { this.editor.activeMapRoot = value; } get mapHistory() { return this.editor.mapHistory; } get onMapChange() { return this.editor.onMapChange; } /** Return whether record is nested below submap. */ isDescendantOf(record, submap) { let parent = record.parentSubmap; while (parent) { if (parent === submap) return true; parent = parent.parentSubmap; } return false; } /** Return whether record belongs to the currently opened map context. */ itemInsideActiveMap(record) { return Boolean(this.activeMapRoot && (record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot))); } /** Return the persistence key for the active map context. */ mapContextKey(root = this.activeMapRoot) { return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root"; } /** * Determine visibility from hidden contexts, active root and expanded parents. * The result controls both item rendering and connector endpoint projection. */ isItemVisible(record) { const context = this.mapContextKey(); if (record !== this.activeMapRoot && record.hiddenContexts.has(context)) return false; if (this.activeMapRoot) { if (record === this.activeMapRoot) return true; if (!this.isDescendantOf(record, this.activeMapRoot)) return false; let parent = record.parentSubmap; while (parent && parent !== this.activeMapRoot) { if (!parent.expanded) return false; parent = parent.parentSubmap; } return parent === this.activeMapRoot; } let parent = record.parentSubmap; while (parent) { if (!parent.expanded) return false; parent = parent.parentSubmap; } return true; } /** Return hidden non-phrase items ordered for the visibility picker. */ hiddenItemsInCurrentContext() { const context = this.mapContextKey(); return this.items .filter((item) => item !== this.activeMapRoot && item.kind !== "phrase" && item.hiddenContexts.has(context)) .sort((left, right) => left.label.localeCompare(right.label)); } /** Return whether the current selection contains an item that can be hidden. */ canHideSelectionInCurrentContext() { return !this.activeMapRoot && this.editor.selectedAll().some((item) => item.parentSubmap && item.kind !== "phrase" && this.isItemVisible(item)); } /** Hide selected child concepts in the current root context. */ hideSelectionInCurrentContext() { const context = this.mapContextKey(); if (this.activeMapRoot) return false; const selected = this.editor.selectedAll().filter((item) => item.parentSubmap && item.kind !== "phrase" && this.isItemVisible(item)); if (!selected.length) return false; this.editor.scheduleHistoryCommit(); for (const item of selected) item.hiddenContexts.add(context); this.editor.clearSelection(); this.refreshVisibility(); if (this.editor.onVisibilityChange) { this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext()); } return true; } /** Show one item again in the current root context. */ showItemInCurrentContext(record) { if (!record) return false; const context = this.mapContextKey(); if (!record.hiddenContexts.has(context)) return false; this.editor.scheduleHistoryCommit(); record.hiddenContexts.delete(context); this.refreshVisibility(); if (this.editor.onVisibilityChange) { this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext()); } return true; } /** Populate a lazy submap once, then reuse its editor records. */ ensureSubmapContents(record) { if (record.submapInitialized) return; record.submapInitialized = true; if (this.editor.onPopulateSubMap) this.editor.onPopulateSubMap(record, this.editor); } /** Expand/collapse a submap or open its separate map representation. */ toggleSubmap(record, expanded = !record.expanded) { if (!record || record.kind !== "submap") return false; if (record.separateMap && !record.cmapSlug) { record.expanded = false; if (record === this.activeMapRoot) { this.refreshVisibility(); return false; } return this.openSubmapMap(record); } this.editor.saveCurrentContextLayout(); if (expanded) this.ensureSubmapContents(record); record.expanded = Boolean(expanded); if (record.expanded) this.editor.applyCurrentContextLayout(); else this.refreshVisibility(); const element = record.node.element(); if (element) this.editor.ensureSubmapToggle(record, element); if (this.editor.onOpenSubMap) this.editor.onOpenSubMap(record, record.expanded); this.editor.scheduleHistoryCommit(); return record.expanded; } /** Open a separate submap and preserve the previous map on the navigation stack. */ openSubmapMap(record) { if (!record || record.kind !== "submap" || !record.separateMap) return false; if (record === this.activeMapRoot) return true; this.ensureSubmapContents(record); this.editor.clearSelection(); this.editor.saveCurrentContextLayout(); if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot); this.activeMapRoot = record; this.editor.applyCurrentContextLayout(); if (this.onMapChange) this.onMapChange(record.mapReference, record); return true; } /** Return from a child context to the root map. */ openRootMap() { if (!this.activeMapRoot) return false; this.editor.clearSelection(); this.editor.saveCurrentContextLayout(); this.activeMapRoot = null; this.editor.mapHistory = []; this.editor.applyCurrentContextLayout(); if (this.onMapChange) this.onMapChange(null, null); return true; } /** Return whether a parent context is available. */ canStepBackWithinMap() { return this.mapHistory.length > 0; } /** Open exactly one parent context from the map navigation stack. */ openParentMap() { if (!this.activeMapRoot) return false; this.editor.clearSelection(); this.editor.saveCurrentContextLayout(); this.activeMapRoot = this.editor.mapHistory.pop() || null; this.editor.applyCurrentContextLayout(); const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); return true; } /** Promote an embedded submap into a separately addressable CMap reference. */ promoteSubmap(record, name) { if (!record || record.kind !== "submap") return null; this.ensureSubmapContents(record); record.childMap = String(name || record.label).trim() || record.label; record.separateMap = true; record.mapReference = { id: `cmap-${record.id}`, title: record.childMap, rootItemId: record.id, itemIds: this.items .filter((item) => this.isDescendantOf(item, record)) .map((item) => item.id) }; this.editor.conceptMaps.set(record.mapReference.id, record.mapReference); record.expanded = false; this.editor.updateItem(record, { synopsis: `Concept map: ${record.childMap}` }); if (this.editor.onSubMapPromoted) this.editor.onSubMapPromoted(record); this.refreshVisibility(); return record.mapReference; } /** Prepare a submap model for storage as a separate CMap. */ prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) { if (!record || record.kind !== "submap") return null; this.ensureSubmapContents(record); return this.editor.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata); } /** Apply one submap record's current frame appearance immediately. */ updateGroupAppearance(record) { const group = this.diagramGroups.get(record); if (!group) return false; group.setAppearance({ label: record.label, backgroundColor: record.submapBackgroundColor || "#edf7e8", borderColor: record.submapBorderColor || "#57834a" }); return true; } /** * Synchronize wiki submap membership with generic engine groups. * Groups own the frame DOM; the editor remains responsible for wiki actions. */ refreshGroups() { if (!this.editor.map || typeof this.editor.map.group !== "function") return; const submaps = this.items .filter((item) => item.kind === "submap") .sort((left, right) => right.submapDepth - left.submapDepth); const current = new Set(submaps); for (const [record, group] of this.diagramGroups) { if (!current.has(record)) { group.destroy(); this.diagramGroups.delete(record); } } for (const record of submaps) { if (this.diagramGroups.has(record)) continue; const group = this.editor.map.group({ label: record.label, className: "rw-cmap-submap-frame", backgroundColor: record.submapBackgroundColor || "#edf7e8", borderColor: record.submapBorderColor || "#57834a", padding: 34, depth: record.submapDepth, expanded: record.expanded, manageVisibility: false, onPointerDown: (event) => { if (event.target?.closest?.(".rw-cmap-submap-frame-toggle")) return; this.editor.startSubmapFrameDrag(event, record); }, onDoubleClick: (event) => { event.preventDefault(); event.stopPropagation(); this.editor.selectItem(record); if (this.editor.onEditItem) this.editor.onEditItem(record); } }); group.onToggle((_group, expanded) => this.toggleSubmap(record, expanded)); this.diagramGroups.set(record, group); } for (const record of submaps) { const group = this.diagramGroups.get(record); for (const member of [...group.members]) group.remove(member); group.expanded = Boolean(record.expanded && record !== this.activeMapRoot && this.isItemVisible(record)); for (const child of this.items.filter((item) => item.parentSubmap === record)) { if (child.kind === "submap") { const childGroup = this.diagramGroups.get(child); if (childGroup) group.add(childGroup); } // A nested submap's anchor is part of this group's layout. Its child // group is added separately so that the nested contents get their own frame. if (child.node) group.add(child.node); } group.setAppearance({ label: record.label, backgroundColor: record.submapBackgroundColor || "#edf7e8", borderColor: record.submapBorderColor || "#57834a" }); group.depth = record.submapDepth; group.redraw(); const element = group.element(); if (element) { element.classList.toggle("rw-cmap-submap-frame-selected", this.editor.selectedItems.has(record)); element.classList.toggle("rw-cmap-submap-frame-selected-primary", this.editor.selectedItems.has(record) && this.editor.selectedItem === record); } this.editor.updateSubmapAnchorLine(record, group.bounds()); } } /** Update selection styling on already rendered group frames. */ refreshGroupSelection() { for (const [record, group] of this.diagramGroups) { const element = group.element(); if (!element) continue; element.classList.toggle("rw-cmap-submap-frame-selected", this.editor.selectedItems.has(record)); element.classList.toggle("rw-cmap-submap-frame-selected-primary", this.editor.selectedItems.has(record) && this.editor.selectedItem === record); } } /** * Reconcile item visibility, projected connector endpoints and submap frames. * The editor still owns the drawing operations; this method coordinates their * order after a context or membership change. */ refreshVisibility() { for (const item of this.items) item.node.visible(this.editor.isEffectiveItemVisible(item)); for (const connector of this.editor.connectors) { this.editor.applyConnectorVisualEndpoints(connector, this.editor.connectorEndpoint(connector.source), this.editor.connectorEndpoint(connector.target)); } this.refreshGroups(); for (const submap of this.items.filter((item) => item.kind === "submap")) { const element = submap.node.element(); if (element) this.editor.ensureSubmapToggle(submap, element); } } }