refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user