341 lines
13 KiB
JavaScript
341 lines
13 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|