refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"use strict";
|
||||
|
||||
import {
|
||||
CmapModel,
|
||||
ConceptMapConcept,
|
||||
ConceptMapConnector,
|
||||
ConceptMapPhrase,
|
||||
PLACEMENT_FIELDS
|
||||
} from "../model/concept-map.js";
|
||||
import { CONCEPT_FIELDS } from "../model/concept-repository.js";
|
||||
import { debugPrefix } from "../cmap-utils.js";
|
||||
|
||||
/**
|
||||
* Keeps the editor's rendered records synchronized with its domain model.
|
||||
*
|
||||
* The editor remains the owner of drawing records and delegates document
|
||||
* operations here. This controller does not store a second copy of items.
|
||||
*/
|
||||
export class CmapDocumentController {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
get model() { return this.editor.model; }
|
||||
get items() { return this.editor.items; }
|
||||
get connectors() { return this.editor.connectors; }
|
||||
get unresolvedConnectors() { return this.editor.unresolvedConnectors; }
|
||||
|
||||
registerModelItem(record) {
|
||||
const existing = this.model.conceptMap.item(record.id);
|
||||
if (existing) return existing;
|
||||
const values = { kind: record.kind };
|
||||
for (const field of PLACEMENT_FIELDS) {
|
||||
if (field === "parentSubmapId") {
|
||||
values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null;
|
||||
} else if (field === "hiddenContexts") {
|
||||
values.hiddenContexts = Array.from(record.hiddenContexts || []);
|
||||
} else if (field === "x" || field === "y" || field === "width" || field === "height") {
|
||||
values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]);
|
||||
} else {
|
||||
values[field] = record[field];
|
||||
}
|
||||
}
|
||||
let modelItem;
|
||||
if (record.kind === "phrase") {
|
||||
modelItem = new ConceptMapPhrase(record.id, {
|
||||
...values,
|
||||
label: record.label,
|
||||
synopsis: record.synopsis
|
||||
});
|
||||
} else {
|
||||
const conceptValues = {};
|
||||
for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field];
|
||||
this.model.repository.ensure(record.conceptId, conceptValues);
|
||||
modelItem = new ConceptMapConcept(record.id, record.conceptId, values);
|
||||
}
|
||||
this.model.conceptMap.addItem(modelItem);
|
||||
return modelItem;
|
||||
}
|
||||
|
||||
bindRecordToModel(record, modelItem) {
|
||||
if (record.modelItem === modelItem) return record;
|
||||
Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true });
|
||||
Object.defineProperty(record, "kind", {
|
||||
configurable: true,
|
||||
get: () => modelItem.kind,
|
||||
set: (value) => { modelItem.kind = value; }
|
||||
});
|
||||
Object.defineProperty(record, "conceptId", {
|
||||
configurable: true,
|
||||
get: () => modelItem.conceptId || null,
|
||||
set: (conceptId) => {
|
||||
if (modelItem instanceof ConceptMapPhrase || !conceptId) return;
|
||||
const current = this.model.repository.concept(modelItem.conceptId);
|
||||
const target = this.model.repository.concept(conceptId) ||
|
||||
this.model.repository.ensure(conceptId, current ? current.toDocument() : {});
|
||||
modelItem.conceptId = target.id;
|
||||
}
|
||||
});
|
||||
for (const field of ["label", "synopsis"]) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] :
|
||||
this.model.repository.requireConcept(modelItem.conceptId)[field],
|
||||
set: (value) => {
|
||||
if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || "");
|
||||
else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelItem instanceof ConceptMapPhrase ? null :
|
||||
this.model.repository.requireConcept(modelItem.conceptId)[field],
|
||||
set: (value) => {
|
||||
if (!(modelItem instanceof ConceptMapPhrase)) {
|
||||
this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const field of PLACEMENT_FIELDS.filter((name) =>
|
||||
name !== "parentSubmapId" && name !== "x" && name !== "y")) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelItem[field],
|
||||
set: (value) => { modelItem[field] = value; }
|
||||
});
|
||||
}
|
||||
Object.defineProperty(record, "parentSubmap", {
|
||||
configurable: true,
|
||||
get: () => modelItem.parentSubmapId === null ? null :
|
||||
this.editor.itemRecord(modelItem.parentSubmapId),
|
||||
set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; }
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
attachRecordToModel(record) {
|
||||
return this.bindRecordToModel(record, this.registerModelItem(record));
|
||||
}
|
||||
|
||||
attachConnectorToModel(record) {
|
||||
let modelConnector = this.model.conceptMap.connector(record.id);
|
||||
if (!modelConnector) {
|
||||
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
|
||||
record.id, record.source.id, record.target.id, record));
|
||||
}
|
||||
Object.defineProperty(record, "modelConnector", {
|
||||
value: modelConnector,
|
||||
configurable: true
|
||||
});
|
||||
for (const field of ["hasArrow", "lineColor", "lineWidth"]) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelConnector[field],
|
||||
set: (value) => { modelConnector[field] = value; }
|
||||
});
|
||||
}
|
||||
for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => this.editor.itemRecord(modelConnector[idField]),
|
||||
set: (value) => { modelConnector[idField] = Number(value.id); }
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
synchronizeModel() {
|
||||
const itemIds = new Set(this.items.map((record) => Number(record.id)));
|
||||
for (const modelItem of this.model.conceptMap.items()) {
|
||||
if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id);
|
||||
}
|
||||
for (const record of this.items) {
|
||||
const modelItem = this.registerModelItem(record);
|
||||
this.bindRecordToModel(record, modelItem);
|
||||
if (record.node) {
|
||||
modelItem.x = Number(record.node.attr("x"));
|
||||
modelItem.y = Number(record.node.attr("y"));
|
||||
modelItem.width = Number(record.node.attr("width"));
|
||||
modelItem.height = Number(record.node.attr("height"));
|
||||
}
|
||||
}
|
||||
const connectorIds = new Set();
|
||||
for (const connector of this.connectors) {
|
||||
connectorIds.add(Number(connector.id));
|
||||
let modelConnector = this.model.conceptMap.connector(connector.id);
|
||||
if (!modelConnector) {
|
||||
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
|
||||
connector.id, connector.source.id, connector.target.id, connector));
|
||||
}
|
||||
modelConnector.sourceId = Number(connector.source.id);
|
||||
modelConnector.targetId = Number(connector.target.id);
|
||||
modelConnector.hasArrow = connector.hasArrow;
|
||||
modelConnector.lineColor = connector.lineColor;
|
||||
modelConnector.lineWidth = connector.lineWidth;
|
||||
}
|
||||
for (const connector of this.unresolvedConnectors) {
|
||||
connectorIds.add(Number(connector.id));
|
||||
if (!this.model.conceptMap.connector(connector.id)) {
|
||||
this.model.conceptMap.addConnector(new ConceptMapConnector(
|
||||
connector.id, connector.sourceId, connector.targetId, connector));
|
||||
}
|
||||
}
|
||||
for (const connector of this.model.conceptMap.connectors()) {
|
||||
if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id);
|
||||
}
|
||||
this.model.conceptMap.metadata = this.editor.documentMetadata;
|
||||
this.model.conceptMap.setConceptMapReferences([...this.editor.conceptMaps.values()]);
|
||||
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
return this.model;
|
||||
}
|
||||
|
||||
historySnapshot() {
|
||||
return JSON.stringify(this.toDocument());
|
||||
}
|
||||
|
||||
clearDocument() {
|
||||
this.editor.clearSelection(false);
|
||||
for (const connector of this.connectors) connector.link.remove();
|
||||
for (const record of this.items) {
|
||||
if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove();
|
||||
record.node.remove();
|
||||
}
|
||||
this.editor.items = [];
|
||||
this.editor.connectors = [];
|
||||
this.editor.unresolvedConnectors = [];
|
||||
this.editor.model = CmapModel.fromDocument({});
|
||||
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.editor.documentMetadata = this.model.conceptMap.metadata;
|
||||
this.editor.activeMapRoot = null;
|
||||
this.editor.mapHistory = [];
|
||||
this.editor.nextId = 1;
|
||||
this.editor.nextConnectorId = 1;
|
||||
if (this.editor.boundaryReferenceView) this.editor.boundaryReferenceView.clear();
|
||||
this.editor.nextGroupId = 1;
|
||||
}
|
||||
|
||||
restoreHistoryDocument(snapshot) {
|
||||
const activeMapRootId = this.editor.activeMapRoot ? this.editor.activeMapRoot.id : null;
|
||||
const mapHistoryIds = this.editor.mapHistory.map((record) => record.id);
|
||||
this.clearDocument();
|
||||
this.editor.loadDocument(JSON.parse(snapshot));
|
||||
this.editor.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null;
|
||||
this.editor.mapHistory = mapHistoryIds
|
||||
.map((id) => this.items.find((item) => item.id === id))
|
||||
.filter(Boolean);
|
||||
this.editor.applyCurrentContextLayout();
|
||||
const reference = this.editor.activeMapRoot ? this.editor.activeMapRoot.mapReference : null;
|
||||
if (this.editor.onMapChange) this.editor.onMapChange(reference, this.editor.activeMapRoot);
|
||||
this.editor.notifySelection();
|
||||
}
|
||||
|
||||
replaceDocument(document) {
|
||||
if (!document || typeof document !== "object") return false;
|
||||
this.editor.history.replace(() => {
|
||||
this.clearDocument();
|
||||
this.loadDocument(document);
|
||||
});
|
||||
this.editor.notifySelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
replaceModel(model) {
|
||||
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
|
||||
return this.replaceDocument(model.toDocument());
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
this.editor.saveCurrentContextLayout();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
return this.synchronizeModel().toDocument();
|
||||
}
|
||||
|
||||
currentModel() {
|
||||
this.editor.saveCurrentContextLayout();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
return this.synchronizeModel();
|
||||
}
|
||||
|
||||
loadModel(model) {
|
||||
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
|
||||
this.loadDocument(model.toDocument());
|
||||
}
|
||||
|
||||
loadDocument(document = {}) {
|
||||
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
|
||||
throw new Error("A concept map document can only be loaded into an empty editor");
|
||||
}
|
||||
this.editor.model = CmapModel.fromDocument(document);
|
||||
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.editor.documentMetadata = this.model.conceptMap.metadata;
|
||||
const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument());
|
||||
const connectorDocuments = this.model.conceptMap.connectors().map((connector) => connector.toDocument());
|
||||
const records = new Map();
|
||||
|
||||
for (const itemDocument of itemDocuments) {
|
||||
const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {};
|
||||
const record = this.editor.addItem({
|
||||
...itemDocument,
|
||||
...concept,
|
||||
id: itemDocument.id,
|
||||
conceptId: itemDocument.conceptId,
|
||||
kind: itemDocument.kind || concept.kind || "concept",
|
||||
parentSubmap: null
|
||||
});
|
||||
record.autoWidth = Boolean(itemDocument.autoWidth);
|
||||
record.autoHeight = Boolean(itemDocument.autoHeight);
|
||||
record.fitContentPending = false;
|
||||
record.expanded = Boolean(itemDocument.expanded);
|
||||
record.submapInitialized = Boolean(itemDocument.submapInitialized);
|
||||
records.set(record.id, record);
|
||||
}
|
||||
const groupNumbers = itemDocuments
|
||||
.map((item) => /^(?:group)-(\d+)$/.exec(item.groupId || ""))
|
||||
.filter(Boolean)
|
||||
.map((match) => Number(match[1]));
|
||||
this.editor.nextGroupId = groupNumbers.length ? Math.max(...groupNumbers) + 1 : 1;
|
||||
for (const itemDocument of itemDocuments) {
|
||||
const record = records.get(Number(itemDocument.id));
|
||||
const parent = records.get(Number(itemDocument.parentSubmapId)) || null;
|
||||
if (record) record.parentSubmap = parent;
|
||||
}
|
||||
for (const connectorDocument of connectorDocuments) {
|
||||
const source = records.get(Number(connectorDocument.sourceId));
|
||||
const target = records.get(Number(connectorDocument.targetId));
|
||||
if (!source || !target) {
|
||||
this.editor.unresolvedConnectors.push({ ...connectorDocument });
|
||||
const unresolvedId = Number(connectorDocument.id);
|
||||
if (Number.isInteger(unresolvedId) && unresolvedId > 0) {
|
||||
this.editor.nextConnectorId = Math.max(this.editor.nextConnectorId, unresolvedId + 1);
|
||||
}
|
||||
console.warn(`${debugPrefix} relation retained without rendering`, {
|
||||
relationId: connectorDocument.id || null,
|
||||
sourceId: connectorDocument.sourceId,
|
||||
targetId: connectorDocument.targetId,
|
||||
missingSource: !source,
|
||||
missingTarget: !target
|
||||
});
|
||||
continue;
|
||||
}
|
||||
this.editor.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
|
||||
}
|
||||
this.editor.reconcilePhraseMembership();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
this.editor.applyCurrentContextLayout();
|
||||
this.editor.clearSelection();
|
||||
if (!this.editor.history.isRestoring) this.editor.resetHistory();
|
||||
return this.editor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use strict";
|
||||
|
||||
/** Own logical diagram geometry, bounds, hit-testing and endpoint projection. */
|
||||
export class CmapGeometryController {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
get items() { return this.editor.items; }
|
||||
get connectors() { return this.editor.connectors; }
|
||||
get zoomFactor() { return this.editor.zoomFactor; }
|
||||
|
||||
logicalCanvasWidth() {
|
||||
const surface = this.editor.surfaceElement();
|
||||
return Math.max(this.editor.canvas.clientWidth / this.zoomFactor,
|
||||
surface ? surface.scrollWidth : 0);
|
||||
}
|
||||
|
||||
logicalCanvasHeight() {
|
||||
const surface = this.editor.surfaceElement();
|
||||
return Math.max(this.editor.canvas.clientHeight / this.zoomFactor,
|
||||
surface ? surface.scrollHeight : 0);
|
||||
}
|
||||
|
||||
updateSubmapDepth(record, depth) {
|
||||
record.submapDepth = depth;
|
||||
for (const child of this.items.filter((item) => item.parentSubmap === record)) {
|
||||
this.updateSubmapDepth(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pointInBounds(point, bounds) {
|
||||
return Boolean(bounds && point.x >= bounds.left && point.x <= bounds.right &&
|
||||
point.y >= bounds.top && point.y <= bounds.bottom);
|
||||
}
|
||||
|
||||
pointNearConnector(point, tolerance = 8 / this.zoomFactor) {
|
||||
const distanceToSegment = (start, end) => {
|
||||
const segmentX = end.x - start.x;
|
||||
const segmentY = end.y - start.y;
|
||||
const lengthSquared = (segmentX * segmentX) + (segmentY * segmentY);
|
||||
if (lengthSquared === 0) return Math.hypot(point.x - start.x, point.y - start.y);
|
||||
const projection = Math.max(0, Math.min(1,
|
||||
(((point.x - start.x) * segmentX) + ((point.y - start.y) * segmentY)) / lengthSquared));
|
||||
const nearestX = start.x + (projection * segmentX);
|
||||
const nearestY = start.y + (projection * segmentY);
|
||||
return Math.hypot(point.x - nearestX, point.y - nearestY);
|
||||
};
|
||||
return this.connectors.some((connector) => {
|
||||
const source = connector.visualSource || this.editor.connectorEndpoint(connector.source);
|
||||
const target = connector.visualTarget || this.editor.connectorEndpoint(connector.target);
|
||||
return source && target && source !== target &&
|
||||
distanceToSegment(this.itemCenter(source), this.itemCenter(target)) <= tolerance;
|
||||
});
|
||||
}
|
||||
|
||||
submapAtPoint(point, excludedRecord = null, excludedRecords = []) {
|
||||
const exclusions = new Set(excludedRecords);
|
||||
const candidates = this.items
|
||||
.filter((item) => item.kind === "submap" && item.expanded && item !== excludedRecord &&
|
||||
!exclusions.has(item) &&
|
||||
(!excludedRecord || !this.editor.isDescendantOf(item, excludedRecord)) &&
|
||||
!excludedRecords.some((record) => this.editor.isDescendantOf(item, record)) &&
|
||||
this.editor.isItemVisible(item))
|
||||
.sort((a, b) => b.submapDepth - a.submapDepth);
|
||||
const match = candidates.find((item) =>
|
||||
this.pointInBounds(point, this.submapBounds(item, excludedRecord)));
|
||||
return match || (this.editor.activeMapRoot && this.editor.activeMapRoot !== excludedRecord &&
|
||||
!exclusions.has(this.editor.activeMapRoot) ? this.editor.activeMapRoot : null);
|
||||
}
|
||||
|
||||
submapBounds(record, excludedRecord = null) {
|
||||
const visibleItems = this.items.filter((item) =>
|
||||
this.editor.isDescendantOf(item, record) && item !== excludedRecord &&
|
||||
(!excludedRecord || !this.editor.isDescendantOf(item, excludedRecord)) &&
|
||||
this.editor.isItemVisible(item));
|
||||
if (visibleItems.length === 0) return null;
|
||||
return {
|
||||
left: Math.min(...visibleItems.map((item) => Number(item.node.attr("x")))) - 34,
|
||||
top: Math.min(...visibleItems.map((item) => Number(item.node.attr("y")))) - 42,
|
||||
right: Math.max(...visibleItems.map((item) =>
|
||||
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 34,
|
||||
bottom: Math.max(...visibleItems.map((item) =>
|
||||
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 34
|
||||
};
|
||||
}
|
||||
|
||||
visualEndpointFor(record) {
|
||||
if (this.editor.isItemVisible(record)) return record;
|
||||
if (record.kind === "phrase" || this.editor.activeMapRoot) return null;
|
||||
let parent = record.parentSubmap;
|
||||
while (parent) {
|
||||
if (this.editor.isItemVisible(parent)) return parent;
|
||||
parent = parent.parentSubmap;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isEffectiveItemVisible(record) {
|
||||
if (!this.editor.isItemVisible(record)) return false;
|
||||
if (record.kind !== "phrase") return true;
|
||||
const neighbours = this.connectors
|
||||
.filter((connector) => connector.source === record || connector.target === record)
|
||||
.map((connector) => connector.source === record ? connector.target : connector.source)
|
||||
.filter((item) => item.kind !== "phrase");
|
||||
return neighbours.every((item) => Boolean(this.visualEndpointFor(item)));
|
||||
}
|
||||
|
||||
connectorEndpoint(record) {
|
||||
return record.kind === "phrase" ?
|
||||
(this.isEffectiveItemVisible(record) ? record : null) : this.visualEndpointFor(record);
|
||||
}
|
||||
|
||||
applyConnectorVisualEndpoints(connector, source, target) {
|
||||
if (!source || !target || source === target) {
|
||||
connector.link.visible(false);
|
||||
return;
|
||||
}
|
||||
if (connector.visualSource !== source) {
|
||||
connector.link.sourceNode(source.node);
|
||||
connector.visualSource = source;
|
||||
}
|
||||
if (connector.visualTarget !== target) {
|
||||
connector.link.targetNode(target.node);
|
||||
connector.visualTarget = target;
|
||||
}
|
||||
connector.link.visible(true);
|
||||
connector.link.straighten();
|
||||
connector.link.redraw();
|
||||
}
|
||||
|
||||
canvasPoint(event) {
|
||||
const rect = this.editor.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (event.clientX - rect.left + this.editor.canvas.scrollLeft) / this.zoomFactor,
|
||||
y: (event.clientY - rect.top + this.editor.canvas.scrollTop) / this.zoomFactor
|
||||
};
|
||||
}
|
||||
|
||||
itemAt(clientX, clientY) {
|
||||
const element = document.elementFromPoint(clientX, clientY);
|
||||
const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null;
|
||||
if (!itemElement) return null;
|
||||
const id = Number(itemElement.dataset.rwCmapItemId);
|
||||
return this.items.find((item) => item.id === id) || null;
|
||||
}
|
||||
|
||||
itemCenter(record) {
|
||||
return {
|
||||
x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2),
|
||||
y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Undo/redo history for an editor whose state can be represented as JSON.
|
||||
*
|
||||
* The history owns snapshots, stack limits and the asynchronous commit boundary.
|
||||
* It does not know how a document is rendered or restored: those concerns are
|
||||
* supplied by the editor through the constructor callbacks.
|
||||
*/
|
||||
export class CmapHistory {
|
||||
/**
|
||||
* goal : Create a history coordinator for one editor state.
|
||||
* pre : snapshot and restore are functions for the same serialized state.
|
||||
* post : The coordinator is ready to track state after reset() is called.
|
||||
* result : A CmapHistory instance with empty undo and redo stacks.
|
||||
* internals : The callbacks keep this controller independent of the editor's
|
||||
* model and view; the stacks contain only serialized snapshots.
|
||||
*
|
||||
* @param {object} options History callbacks and configuration.
|
||||
* @param {Function} options.snapshot Returns the current serialized state.
|
||||
* @param {Function} options.restore Restores one serialized state.
|
||||
* @param {Function} [options.onChange] Receives undo/redo availability changes.
|
||||
* @param {number} [options.limit=100] Maximum number of undo snapshots.
|
||||
*/
|
||||
constructor({ snapshot, restore, onChange = null, limit = 100 }) {
|
||||
if (typeof snapshot !== "function") throw new TypeError("A snapshot function is required");
|
||||
if (typeof restore !== "function") throw new TypeError("A restore function is required");
|
||||
this.snapshot = snapshot;
|
||||
this.restoreDocument = restore;
|
||||
this.onChange = onChange;
|
||||
this.limit = Math.max(1, Number(limit) || 100);
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.currentSnapshot = null;
|
||||
this.timer = null;
|
||||
this.ready = false;
|
||||
this.isRestoring = false;
|
||||
}
|
||||
|
||||
/** Notify the host about the current availability of undo and redo. */
|
||||
notify() {
|
||||
if (this.onChange) {
|
||||
this.onChange({
|
||||
canUndo: this.canUndo(),
|
||||
canRedo: this.canRedo()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Start a new history session at the current editor state.
|
||||
* pre : The snapshot callback returns the current serialized state.
|
||||
* post : Both stacks are empty and the current state is the history baseline.
|
||||
* result : Undefined; the host is notified of the empty stacks.
|
||||
* internals : A pending timer is cancelled before the baseline is captured.
|
||||
*/
|
||||
reset() {
|
||||
this.cancelScheduledCommit();
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.ready = true;
|
||||
this.currentSnapshot = this.snapshot();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Cancel a pending asynchronous history commit. */
|
||||
cancelScheduledCommit() {
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one commit for the current mutation transaction.
|
||||
* The zero-delay timer groups synchronous editor changes into one undo step.
|
||||
*/
|
||||
scheduleCommit() {
|
||||
if (!this.ready || this.isRestoring) return;
|
||||
this.cancelScheduledCommit();
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.commit();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Update the baseline after renderer-only normalization. */
|
||||
refreshSnapshot() {
|
||||
if (!this.ready || this.isRestoring || this.timer !== null) return;
|
||||
this.currentSnapshot = this.snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the current state when it differs from the baseline.
|
||||
* @returns {boolean} Whether a new undo step was recorded.
|
||||
*/
|
||||
commit() {
|
||||
if (!this.ready || this.isRestoring) return false;
|
||||
this.cancelScheduledCommit();
|
||||
const nextSnapshot = this.snapshot();
|
||||
if (nextSnapshot === this.currentSnapshot) return false;
|
||||
if (this.currentSnapshot !== null) {
|
||||
this.undoStack.push(this.currentSnapshot);
|
||||
if (this.undoStack.length > this.limit) this.undoStack.shift();
|
||||
}
|
||||
this.currentSnapshot = nextSnapshot;
|
||||
this.redoStack = [];
|
||||
this.notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Return whether an undo operation is available. */
|
||||
canUndo() {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Return whether a redo operation is available. */
|
||||
canRedo() {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
hasPendingCommit() {
|
||||
return this.timer !== null;
|
||||
}
|
||||
|
||||
get undoCount() {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
|
||||
get redoCount() {
|
||||
return this.redoStack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one snapshot while suppressing history commits caused by loading.
|
||||
* The editor callback performs the actual model and view reconstruction.
|
||||
*/
|
||||
restoreSnapshot(snapshot) {
|
||||
this.isRestoring = true;
|
||||
try {
|
||||
this.restoreDocument(snapshot);
|
||||
} finally {
|
||||
this.isRestoring = false;
|
||||
}
|
||||
this.currentSnapshot = snapshot;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Restore the previous committed state, if one exists. */
|
||||
undo() {
|
||||
this.commit();
|
||||
if (!this.canUndo()) return false;
|
||||
this.redoStack.push(this.currentSnapshot);
|
||||
const snapshot = this.undoStack.pop();
|
||||
this.restoreSnapshot(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Restore the most recently undone state, if one exists. */
|
||||
redo() {
|
||||
this.commit();
|
||||
if (!this.canRedo()) return false;
|
||||
this.undoStack.push(this.currentSnapshot);
|
||||
const snapshot = this.redoStack.pop();
|
||||
this.restoreSnapshot(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Replace the current document as one undoable operation.
|
||||
* pre : replaceDocument performs the complete document replacement.
|
||||
* post : The replacement is current and redo history has been discarded.
|
||||
* result : Undefined; the host receives the new undo/redo availability.
|
||||
* internals : The old baseline is pushed before the callback runs, while
|
||||
* isRestoring prevents loading callbacks from creating nested history steps.
|
||||
*/
|
||||
replace(replaceDocument) {
|
||||
this.commit();
|
||||
const previousSnapshot = this.currentSnapshot || this.snapshot();
|
||||
this.isRestoring = true;
|
||||
try {
|
||||
replaceDocument();
|
||||
} finally {
|
||||
this.isRestoring = false;
|
||||
}
|
||||
const nextSnapshot = this.snapshot();
|
||||
if (previousSnapshot !== nextSnapshot) {
|
||||
this.undoStack.push(previousSnapshot);
|
||||
if (this.undoStack.length > this.limit) this.undoStack.shift();
|
||||
}
|
||||
this.currentSnapshot = nextSnapshot;
|
||||
this.redoStack = [];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Release the timer when the owning editor is destroyed. */
|
||||
destroy() {
|
||||
this.cancelScheduledCommit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
"use strict";
|
||||
|
||||
import { debug, elementDescription } from "../cmap-utils.js";
|
||||
|
||||
export class CmapInteractionController {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
this.marqueeMouseDownHandler = null;
|
||||
this.activeMarqueeCleanup = null;
|
||||
this.canvasPanPointerDownHandler = null;
|
||||
this.activeCanvasPanCleanup = null;
|
||||
this.boundaryRefreshScheduled = false;
|
||||
}
|
||||
|
||||
get items() { return this.editor.items; }
|
||||
get connectors() { return this.editor.connectors; }
|
||||
get canvas() { return this.editor.canvas; }
|
||||
get zoomFactor() { return this.editor.zoomFactor; }
|
||||
|
||||
handleItemMove(record, x, y) {
|
||||
const movesCompleteSubmap = false;
|
||||
const movesSelection = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1;
|
||||
const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection);
|
||||
if (moveMembership.groupPositions.length > 1) {
|
||||
this.moveSubmapGroup(record, x, y, moveMembership);
|
||||
queueMicrotask(() => this.redrawAllConnectors());
|
||||
}
|
||||
this.scheduleBoundaryRefresh();
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
scheduleBoundaryRefresh() {
|
||||
if (this.boundaryRefreshScheduled) return;
|
||||
this.boundaryRefreshScheduled = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
this.boundaryRefreshScheduled = false;
|
||||
if (!this.editor.destroyed) this.editor.refreshBoundaryReferences();
|
||||
});
|
||||
}
|
||||
|
||||
beginItemMove(record, includeDescendants = false) {
|
||||
if (record.moveMembership) return record.moveMembership;
|
||||
let groupItems = [record];
|
||||
if (includeDescendants) {
|
||||
const selected = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1 ?
|
||||
this.editor.selectedAll() : [record];
|
||||
const expanded = [];
|
||||
for (const item of selected) {
|
||||
expanded.push(item);
|
||||
if (item.kind === "submap" && item !== this.editor.activeMapRoot) {
|
||||
expanded.push(...this.items.filter((candidate) => this.editor.isDescendantOf(candidate, item)));
|
||||
}
|
||||
}
|
||||
groupItems = Array.from(new Set(expanded));
|
||||
}
|
||||
record.moveMembership = {
|
||||
parent: record.parentSubmap,
|
||||
parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null,
|
||||
startX: Number(record.node.attr("x")),
|
||||
startY: Number(record.node.attr("y")),
|
||||
groupPositions: groupItems.map((item) => ({
|
||||
item,
|
||||
x: Number(item.node.attr("x")),
|
||||
y: Number(item.node.attr("y"))
|
||||
}))
|
||||
};
|
||||
return record.moveMembership;
|
||||
}
|
||||
|
||||
moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
|
||||
const deltaX = x - moveMembership.startX;
|
||||
const deltaY = y - moveMembership.startY;
|
||||
for (const position of moveMembership.groupPositions) {
|
||||
position.item.node.attr({
|
||||
x: position.x + deltaX,
|
||||
y: position.y + deltaY
|
||||
});
|
||||
position.item.node.redraw();
|
||||
}
|
||||
for (const connector of this.connectors) {
|
||||
connector.link.straighten();
|
||||
connector.link.redraw();
|
||||
}
|
||||
for (const submap of this.items
|
||||
.filter((item) => item.kind === "submap")
|
||||
.sort((a, b) => b.submapDepth - a.submapDepth)) {
|
||||
this.editor.updateSubmapFrame(submap);
|
||||
}
|
||||
}
|
||||
|
||||
redrawAllConnectors() {
|
||||
for (const connector of this.connectors) {
|
||||
connector.link.straighten();
|
||||
connector.link.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
handleItemMoveEnd(record) {
|
||||
const moveMembership = record.moveMembership;
|
||||
record.moveMembership = null;
|
||||
if (!moveMembership) return;
|
||||
this.redrawAllConnectors();
|
||||
|
||||
const movedItems = this.editor.selectedAll().filter((item) =>
|
||||
moveMembership.groupPositions.some((position) => position.item === item));
|
||||
const movedParents = new Set(movedItems.map((item) => item.parentSubmap));
|
||||
if (movedItems.length > 1 && movedParents.size === 1) {
|
||||
const previousParent = movedItems[0].parentSubmap;
|
||||
const centers = movedItems.map((item) => this.editor.itemCenter(item));
|
||||
const center = {
|
||||
x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length,
|
||||
y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length
|
||||
};
|
||||
let parent = null;
|
||||
if (previousParent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
|
||||
parent = previousParent;
|
||||
} else {
|
||||
parent = this.editor.submapAtPoint(center, null, movedItems);
|
||||
}
|
||||
if (!parent && this.editor.activeMapRoot && !movedItems.includes(this.editor.activeMapRoot)) {
|
||||
parent = this.editor.activeMapRoot;
|
||||
}
|
||||
if (previousParent && parent !== previousParent && this.editor.onConfirmDetachFromSubmap &&
|
||||
!this.editor.onConfirmDetachFromSubmap(record, previousParent, parent)) {
|
||||
parent = previousParent;
|
||||
}
|
||||
if (parent !== previousParent) {
|
||||
for (const item of movedItems) {
|
||||
if (item === this.editor.activeMapRoot) continue;
|
||||
item.parentSubmap = parent;
|
||||
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
|
||||
}
|
||||
this.editor.reconcilePhraseMembership();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
debug("selection submap membership changed", {
|
||||
itemIds: movedItems.map((item) => item.id),
|
||||
previousParentId: previousParent ? previousParent.id : null,
|
||||
parentId: parent ? parent.id : null
|
||||
});
|
||||
}
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.kind === "phrase") {
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (record === this.editor.activeMapRoot) {
|
||||
this.editor.refreshSubmapVisibility();
|
||||
debug("active map head moved without changing parent membership", {
|
||||
id: record.id,
|
||||
parentId: record.parentSubmap ? record.parentSubmap.id : null
|
||||
});
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return;
|
||||
}
|
||||
|
||||
const center = this.editor.itemCenter(record);
|
||||
let parent = null;
|
||||
if (moveMembership.parent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
|
||||
parent = moveMembership.parent;
|
||||
} else {
|
||||
parent = this.editor.submapAtPoint(center, record);
|
||||
}
|
||||
if (!parent && this.editor.activeMapRoot && record !== this.editor.activeMapRoot) parent = this.editor.activeMapRoot;
|
||||
|
||||
if (moveMembership.parent && parent !== moveMembership.parent &&
|
||||
this.editor.onConfirmDetachFromSubmap &&
|
||||
!this.editor.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) {
|
||||
parent = moveMembership.parent;
|
||||
}
|
||||
|
||||
if (parent !== record.parentSubmap) {
|
||||
const previousParent = record.parentSubmap;
|
||||
record.parentSubmap = parent;
|
||||
this.editor.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0);
|
||||
debug("item submap membership changed", {
|
||||
id: record.id,
|
||||
previousParentId: previousParent ? previousParent.id : null,
|
||||
parentId: parent ? parent.id : null
|
||||
});
|
||||
this.editor.reconcilePhraseMembership();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
}
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.scheduleHistoryCommit();
|
||||
}
|
||||
|
||||
startSubmapFrameDrag(event, record) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
|
||||
if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(record)) {
|
||||
this.editor.selectedItem = record;
|
||||
this.editor.refreshSelectionDecoration();
|
||||
this.editor.notifySelection();
|
||||
} else {
|
||||
this.editor.selectItem(record, { additive });
|
||||
}
|
||||
const pointerId = event.pointerId;
|
||||
const startClientX = event.clientX;
|
||||
const startClientY = event.clientY;
|
||||
const bounds = this.editor.submapBounds(record);
|
||||
if (!bounds) return;
|
||||
const moveMembership = {
|
||||
parent: record.parentSubmap,
|
||||
parentBounds: null,
|
||||
startX: bounds.left,
|
||||
startY: bounds.top,
|
||||
groupPositions: this.items
|
||||
.filter((item) => this.editor.isDescendantOf(item, record))
|
||||
.map((item) => ({
|
||||
item,
|
||||
x: Number(item.node.attr("x")),
|
||||
y: Number(item.node.attr("y"))
|
||||
}))
|
||||
};
|
||||
record.moveMembership = moveMembership;
|
||||
|
||||
const move = (moveEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
moveEvent.preventDefault();
|
||||
const x = moveMembership.startX +
|
||||
((moveEvent.clientX - startClientX) / this.zoomFactor);
|
||||
const y = moveMembership.startY +
|
||||
((moveEvent.clientY - startClientY) / this.zoomFactor);
|
||||
this.moveSubmapGroup(record, x, y, moveMembership);
|
||||
};
|
||||
|
||||
const up = (upEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) return;
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
record.moveMembership = null;
|
||||
this.editor.saveCurrentContextLayout();
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.scheduleHistoryCommit();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
installMarqueeSelection() {
|
||||
this.marqueeMouseDownHandler = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest(
|
||||
"[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
|
||||
|
||||
const start = this.editor.canvasPoint(event);
|
||||
if (this.editor.pointNearConnector(start)) return;
|
||||
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
|
||||
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
|
||||
const surface = this.editor.surfaceElement() || this.canvas;
|
||||
const marquee = document.createElement("div");
|
||||
marquee.className = "rw-cmap-marquee";
|
||||
Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" });
|
||||
surface.append(marquee);
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
marquee.remove();
|
||||
if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null;
|
||||
};
|
||||
|
||||
const move = (moveEvent) => {
|
||||
const point = this.editor.canvasPoint(moveEvent);
|
||||
const left = Math.min(start.x, point.x);
|
||||
const top = Math.min(start.y, point.y);
|
||||
Object.assign(marquee.style, {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${Math.abs(point.x - start.x)}px`,
|
||||
height: `${Math.abs(point.y - start.y)}px`
|
||||
});
|
||||
};
|
||||
|
||||
const up = (upEvent) => {
|
||||
const point = this.editor.canvasPoint(upEvent);
|
||||
cleanup();
|
||||
const bounds = {
|
||||
left: Math.min(start.x, point.x),
|
||||
top: Math.min(start.y, point.y),
|
||||
right: Math.max(start.x, point.x),
|
||||
bottom: Math.max(start.y, point.y)
|
||||
};
|
||||
if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) {
|
||||
if (!additive) this.editor.clearSelection();
|
||||
return;
|
||||
}
|
||||
if (!additive) this.editor.clearSelection(false);
|
||||
const matches = this.items.filter((item) => {
|
||||
if (!this.editor.isEffectiveItemVisible(item)) return false;
|
||||
const left = Number(item.node.attr("x"));
|
||||
const top = Number(item.node.attr("y"));
|
||||
const right = left + Number(item.node.attr("width"));
|
||||
const bottom = top + Number(item.node.attr("height"));
|
||||
return right >= bounds.left && left <= bounds.right &&
|
||||
bottom >= bounds.top && top <= bounds.bottom;
|
||||
});
|
||||
const expanded = new Set(matches);
|
||||
for (const item of matches) {
|
||||
if (!item.groupId) continue;
|
||||
for (const member of this.items.filter((candidate) =>
|
||||
candidate.groupId === item.groupId && this.editor.isItemVisible(candidate))) expanded.add(member);
|
||||
}
|
||||
for (const item of expanded) this.editor.selectedItems.add(item);
|
||||
this.editor.selectedItem = matches.at(-1) || this.editor.selectedItem;
|
||||
this.editor.selectedConnector = null;
|
||||
this.editor.refreshSelectionDecoration();
|
||||
this.editor.notifySelection();
|
||||
debug("marquee selection applied", {
|
||||
selectedIds: this.editor.selectedAll().map((item) => item.id)
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
this.activeMarqueeCleanup = cleanup;
|
||||
};
|
||||
this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler);
|
||||
}
|
||||
|
||||
installCanvasPanning() {
|
||||
this.canvasPanPointerDownHandler = (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target && target.closest(
|
||||
"[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
|
||||
if (event.button !== 1 && !(event.button === 0 && event.altKey)) return;
|
||||
event.preventDefault();
|
||||
if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startScrollLeft = this.canvas.scrollLeft;
|
||||
const startScrollTop = this.canvas.scrollTop;
|
||||
const pointerId = event.pointerId;
|
||||
this.canvas.classList.add("rw-cmap-canvas-panning");
|
||||
|
||||
const move = (moveEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
moveEvent.preventDefault();
|
||||
this.canvas.scrollLeft = startScrollLeft - (moveEvent.clientX - startX);
|
||||
this.canvas.scrollTop = startScrollTop - (moveEvent.clientY - startY);
|
||||
};
|
||||
const up = (upEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) return;
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
this.canvas.classList.remove("rw-cmap-canvas-panning");
|
||||
if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
this.canvas.classList.remove("rw-cmap-canvas-panning");
|
||||
if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
this.activeCanvasPanCleanup = cleanup;
|
||||
};
|
||||
this.canvas.addEventListener("pointerdown", this.canvasPanPointerDownHandler);
|
||||
}
|
||||
|
||||
handleMapSelection(component, event) {
|
||||
if (event.target instanceof Element &&
|
||||
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
|
||||
debug("cmap selection belongs to an editor control", elementDescription(event.target));
|
||||
return;
|
||||
}
|
||||
|
||||
const item = this.items.find((candidate) => candidate.node === component) || null;
|
||||
const connector = this.connectors.find((candidate) => candidate.link === component) || null;
|
||||
debug("selection callback received from cmap hit test", {
|
||||
componentFound: Boolean(component),
|
||||
itemId: item ? item.id : null,
|
||||
connectorId: connector ? connector.id : null,
|
||||
target: elementDescription(event.target)
|
||||
});
|
||||
|
||||
if (item) {
|
||||
const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey));
|
||||
if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(item)) {
|
||||
this.editor.selectedItem = item;
|
||||
this.editor.refreshSelectionDecoration();
|
||||
this.editor.notifySelection();
|
||||
return;
|
||||
}
|
||||
this.editor.selectItem(item, { additive, toggle: additive });
|
||||
return;
|
||||
}
|
||||
if (connector) {
|
||||
this.editor.selectConnector(connector);
|
||||
return;
|
||||
}
|
||||
if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.editor.clearSelection();
|
||||
}
|
||||
|
||||
handleMapActivation(component, event) {
|
||||
const item = this.items.find((candidate) => candidate.node === component) || null;
|
||||
debug("activation callback received from cmap", {
|
||||
itemId: item ? item.id : null,
|
||||
kind: item ? item.kind : null,
|
||||
pageSlug: item ? item.pageSlug : null
|
||||
});
|
||||
if (!item) return;
|
||||
if (event && event.preventDefault) event.preventDefault();
|
||||
if (item.kind === "phrase") {
|
||||
this.editor.editPhraseInline(item);
|
||||
return;
|
||||
}
|
||||
if (this.editor.onEditItem) {
|
||||
this.editor.selectItem(item);
|
||||
this.editor.onEditItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
startResize(event, record) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
record.autoWidth = false;
|
||||
record.autoHeight = false;
|
||||
record.fitContentPending = false;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startWidth = Number(record.node.attr("width"));
|
||||
const startHeight = Number(record.node.attr("height"));
|
||||
const pointerId = event.pointerId;
|
||||
event.currentTarget.setPointerCapture(pointerId);
|
||||
|
||||
const move = (moveEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor));
|
||||
record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor));
|
||||
record.node.attr({ width: record.width, height: record.height });
|
||||
record.node.redraw();
|
||||
this.editor.decorateItem(record);
|
||||
this.editor.redrawConnectorsFor(record);
|
||||
this.editor.ensureCanvasExtent(Number(record.node.attr("x")) + record.width,
|
||||
Number(record.node.attr("y")) + record.height);
|
||||
};
|
||||
|
||||
const up = (upEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) return;
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
this.editor.decorateItem(record);
|
||||
this.editor.scheduleHistoryCommit();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
startRelationDrag(event, source) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const pointerId = event.pointerId;
|
||||
const start = this.editor.itemCenter(source);
|
||||
const draft = this.createDraftLine(start);
|
||||
this.editor.dragRelation = { source, draft };
|
||||
event.currentTarget.setPointerCapture(pointerId);
|
||||
|
||||
const move = (moveEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
const point = this.editor.canvasPoint(moveEvent);
|
||||
this.editor.ensureCanvasExtent(point.x, point.y);
|
||||
draft.line.setAttribute("x2", String(point.x));
|
||||
draft.line.setAttribute("y2", String(point.y));
|
||||
draft.svg.setAttribute("width", String(Math.max(this.editor.logicalCanvasWidth(), point.x + 180)));
|
||||
draft.svg.setAttribute("height", String(Math.max(this.editor.logicalCanvasHeight(), point.y + 180)));
|
||||
};
|
||||
|
||||
const up = (upEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) return;
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
const target = this.editor.itemAt(upEvent.clientX, upEvent.clientY);
|
||||
const point = this.editor.canvasPoint(upEvent);
|
||||
draft.svg.remove();
|
||||
this.editor.dragRelation = null;
|
||||
if (!target) {
|
||||
this.editor.ensureCanvasExtent(point.x, point.y);
|
||||
const parentSubmap = this.editor.submapAtPoint(point);
|
||||
debug("relation dropped on empty canvas", {
|
||||
sourceId: source.id,
|
||||
point,
|
||||
parentSubmapId: parentSubmap ? parentSubmap.id : null
|
||||
});
|
||||
if (this.editor.onCreateConnectedItem) this.editor.onCreateConnectedItem({ source, point, parentSubmap });
|
||||
return;
|
||||
}
|
||||
if (target === source) return;
|
||||
this.finishRelation(source, target, event.altKey || upEvent.altKey);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
finishRelation(source, target, direct = false) {
|
||||
if (source.kind === "phrase" && target.kind !== "phrase") {
|
||||
this.editor.addConnector(source, target, true);
|
||||
this.editor.reconcilePhraseMembership(source);
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.selectItem(source);
|
||||
return;
|
||||
}
|
||||
if (source.kind !== "phrase" && target.kind === "phrase") {
|
||||
this.editor.addConnector(source, target, false);
|
||||
this.editor.reconcilePhraseMembership(target);
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.selectItem(target);
|
||||
return;
|
||||
}
|
||||
if (source.kind === "phrase" && target.kind === "phrase") return;
|
||||
if (direct) {
|
||||
const connector = this.editor.addConnector(source, target, true);
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.selectConnector(connector);
|
||||
return;
|
||||
}
|
||||
this.editor.connectWithPhrase(source, target, "?????", true);
|
||||
}
|
||||
|
||||
createDraftLine(start) {
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(ns, "svg");
|
||||
svg.classList.add("rw-cmap-draft-layer");
|
||||
svg.setAttribute("width", String(this.editor.logicalCanvasWidth()));
|
||||
svg.setAttribute("height", String(this.editor.logicalCanvasHeight()));
|
||||
const line = document.createElementNS(ns, "line");
|
||||
line.setAttribute("x1", String(start.x));
|
||||
line.setAttribute("y1", String(start.y));
|
||||
line.setAttribute("x2", String(start.x));
|
||||
line.setAttribute("y2", String(start.y));
|
||||
line.setAttribute("class", "rw-cmap-draft-line");
|
||||
svg.append(line);
|
||||
(this.editor.surfaceElement() || this.canvas).append(svg);
|
||||
return { svg, line };
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.marqueeMouseDownHandler) {
|
||||
this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler);
|
||||
this.marqueeMouseDownHandler = null;
|
||||
}
|
||||
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
|
||||
if (this.canvasPanPointerDownHandler) {
|
||||
this.canvas.removeEventListener("pointerdown", this.canvasPanPointerDownHandler);
|
||||
this.canvasPanPointerDownHandler = null;
|
||||
}
|
||||
if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use strict";
|
||||
|
||||
/** Own map-context layouts and apply them to the rendered editor records. */
|
||||
export class CmapLayoutController {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
itemLayout(record) {
|
||||
return {
|
||||
x: Number(record.node.attr("x")),
|
||||
y: Number(record.node.attr("y")),
|
||||
width: Number(record.node.attr("width")),
|
||||
height: Number(record.node.attr("height")),
|
||||
backgroundColor: record.backgroundColor,
|
||||
borderColor: record.borderColor,
|
||||
textColor: record.textColor,
|
||||
fontFamily: record.fontFamily,
|
||||
fontSize: record.fontSize,
|
||||
fontWeight: record.fontWeight,
|
||||
fontStyle: record.fontStyle,
|
||||
synopsisTextColor: record.synopsisTextColor,
|
||||
synopsisFontFamily: record.synopsisFontFamily,
|
||||
synopsisFontSize: record.synopsisFontSize,
|
||||
synopsisFontWeight: record.synopsisFontWeight,
|
||||
synopsisFontStyle: record.synopsisFontStyle
|
||||
};
|
||||
}
|
||||
|
||||
saveCurrentContextLayout() {
|
||||
const context = this.editor.mapContextKey();
|
||||
for (const record of this.editor.items.filter((item) => this.editor.isEffectiveItemVisible(item))) {
|
||||
record.layouts[context] = this.itemLayout(record);
|
||||
}
|
||||
}
|
||||
|
||||
applyCurrentContextLayout() {
|
||||
const context = this.editor.mapContextKey();
|
||||
const visible = this.editor.items.filter((record) => this.editor.isEffectiveItemVisible(record));
|
||||
let offset = { x: 0, y: 0 };
|
||||
if (this.editor.activeMapRoot && !this.editor.activeMapRoot.layouts[context]) {
|
||||
const rootLayout = this.itemLayout(this.editor.activeMapRoot);
|
||||
offset = { x: 80 - rootLayout.x, y: 80 - rootLayout.y };
|
||||
}
|
||||
|
||||
for (const record of visible) {
|
||||
if (!record.layouts[context]) {
|
||||
const current = this.itemLayout(record);
|
||||
record.layouts[context] = { ...current, x: current.x + offset.x, y: current.y + offset.y };
|
||||
}
|
||||
const layout = record.layouts[context];
|
||||
record.width = layout.width;
|
||||
record.height = layout.height;
|
||||
record.backgroundColor = layout.backgroundColor || record.backgroundColor;
|
||||
record.borderColor = layout.borderColor || record.borderColor;
|
||||
record.textColor = layout.textColor || record.textColor;
|
||||
record.fontFamily = layout.fontFamily || record.fontFamily;
|
||||
record.fontSize = layout.fontSize || record.fontSize;
|
||||
record.fontWeight = layout.fontWeight || record.fontWeight;
|
||||
record.fontStyle = layout.fontStyle || record.fontStyle;
|
||||
record.synopsisTextColor = layout.synopsisTextColor || layout.textColor || record.synopsisTextColor;
|
||||
record.synopsisFontFamily = layout.synopsisFontFamily || layout.fontFamily || record.synopsisFontFamily;
|
||||
record.synopsisFontSize = layout.synopsisFontSize || record.synopsisFontSize;
|
||||
record.synopsisFontWeight = layout.synopsisFontWeight || layout.fontWeight || record.synopsisFontWeight;
|
||||
record.synopsisFontStyle = layout.synopsisFontStyle || layout.fontStyle || record.synopsisFontStyle;
|
||||
record.node.attr({
|
||||
x: layout.x,
|
||||
y: layout.y,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
content: this.editor.itemHtml(record),
|
||||
backgroundColor: record.backgroundColor,
|
||||
borderColor: record.borderColor,
|
||||
textColor: record.textColor
|
||||
});
|
||||
record.node.redraw();
|
||||
}
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.refreshConnectorGeometry();
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!this.editor.destroyed && this.editor.canvas.isConnected) {
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.refreshConnectorGeometry();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use strict";
|
||||
|
||||
import { ConceptMapConnector } from "../model/concept-map.js";
|
||||
import { debug, numberOr } from "../cmap-utils.js";
|
||||
|
||||
/** Own map-local connector mutations and linking-phrase membership. */
|
||||
export class CmapRelationController {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
get items() { return this.editor.items; }
|
||||
get connectors() { return this.editor.connectors; }
|
||||
|
||||
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
|
||||
const a = this.editor.itemCenter(source);
|
||||
const b = this.editor.itemCenter(target);
|
||||
const parentSubmap = this.commonSubmapParent([source, target]);
|
||||
const phrase = this.editor.addItem({
|
||||
kind: "phrase",
|
||||
label,
|
||||
parentSubmap,
|
||||
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
|
||||
x: ((a.x + b.x) / 2) - 72,
|
||||
y: ((a.y + b.y) / 2) - 18,
|
||||
backgroundColor: "#fbfbf8",
|
||||
borderColor: "transparent"
|
||||
});
|
||||
this.editor.addConnector(source, phrase, false);
|
||||
this.editor.addConnector(phrase, target, true);
|
||||
this.reconcilePhraseMembership(phrase);
|
||||
this.editor.selectItem(phrase);
|
||||
if (editImmediately) this.editor.editPhraseInline(phrase);
|
||||
return phrase;
|
||||
}
|
||||
|
||||
submapChain(record) {
|
||||
const result = [];
|
||||
if (record.kind === "submap") result.push(record);
|
||||
let parent = record.parentSubmap;
|
||||
while (parent) {
|
||||
result.push(parent);
|
||||
parent = parent.parentSubmap;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
commonSubmapParent(records) {
|
||||
if (!records.length) return null;
|
||||
const chains = records.map((record) => this.submapChain(record));
|
||||
return chains[0]
|
||||
.filter((candidate) => chains.every((chain) => chain.includes(candidate)))
|
||||
.sort((a, b) => b.submapDepth - a.submapDepth)[0] || null;
|
||||
}
|
||||
|
||||
reconcilePhraseMembership(phrase = null) {
|
||||
const phrases = phrase ? [phrase] : this.items.filter((item) => item.kind === "phrase");
|
||||
for (const item of phrases) {
|
||||
const endpoints = this.connectors
|
||||
.filter((connector) => connector.source === item || connector.target === item)
|
||||
.map((connector) => connector.source === item ? connector.target : connector.source)
|
||||
.filter((endpoint) => endpoint.kind !== "phrase");
|
||||
if (!endpoints.length) continue;
|
||||
const parent = this.commonSubmapParent(endpoints);
|
||||
item.parentSubmap = parent;
|
||||
item.submapDepth = parent ? parent.submapDepth + 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
addConnector(source, target, hasArrow = true, options = {}) {
|
||||
const sourceCenter = this.editor.itemCenter(source);
|
||||
const targetCenter = this.editor.itemCenter(target);
|
||||
const link = this.editor.view.createConnector({
|
||||
content: "",
|
||||
width: 1,
|
||||
height: 1,
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "transparent",
|
||||
borderWidth: 0,
|
||||
lineColor: options.lineColor || "#333",
|
||||
lineWidth: numberOr(Number(options.lineWidth), 2),
|
||||
hasArrow,
|
||||
cx: (sourceCenter.x + targetCenter.x) / 2,
|
||||
cy: (sourceCenter.y + targetCenter.y) / 2,
|
||||
sourceX: sourceCenter.x,
|
||||
sourceY: sourceCenter.y,
|
||||
targetX: targetCenter.x,
|
||||
targetY: targetCenter.y
|
||||
});
|
||||
link.sourceNode(source.node).targetNode(target.node);
|
||||
link.straighten();
|
||||
link.draggable(true);
|
||||
|
||||
const record = {
|
||||
id: Number.isInteger(Number(options.id)) && Number(options.id) > 0 ?
|
||||
Number(options.id) : this.editor.nextConnectorId,
|
||||
link,
|
||||
source,
|
||||
target,
|
||||
visualSource: source,
|
||||
visualTarget: target,
|
||||
hasArrow,
|
||||
lineColor: options.lineColor || "#333",
|
||||
lineWidth: numberOr(Number(options.lineWidth), 2)
|
||||
};
|
||||
this.editor.nextConnectorId = Math.max(this.editor.nextConnectorId, record.id + 1);
|
||||
this.editor.documents.attachConnectorToModel(record);
|
||||
this.connectors.push(record);
|
||||
link.onRendered((_renderedLink, element) => this.editor.decorateConnector(record, element));
|
||||
link.onConnectionChange((_changedLink, type, node) =>
|
||||
this.editor.handleConnectorConnectionChange(record, type, node));
|
||||
link.visible(this.editor.isItemVisible(source) && this.editor.isItemVisible(target));
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return record;
|
||||
}
|
||||
|
||||
handleConnectorConnectionChange(connector, type, node) {
|
||||
if (!connector || (type !== "source" && type !== "target")) return false;
|
||||
const endpoint = this.items.find((item) => item.node === node) || null;
|
||||
const otherType = type === "source" ? "target" : "source";
|
||||
if (!endpoint || endpoint === connector[otherType]) {
|
||||
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
|
||||
this.editor.applyConnectorVisualEndpoints(connector,
|
||||
this.editor.connectorEndpoint(connector.source),
|
||||
this.editor.connectorEndpoint(connector.target));
|
||||
return false;
|
||||
}
|
||||
if (connector[type] === endpoint) {
|
||||
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
|
||||
return false;
|
||||
}
|
||||
const previous = connector[type];
|
||||
connector[type] = endpoint;
|
||||
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
|
||||
this.reconcilePhraseMembership();
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.editor.refreshConnectorGeometry();
|
||||
debug("connector endpoint changed", {
|
||||
connectorId: connector.id,
|
||||
type,
|
||||
previousItemId: previous ? previous.id : null,
|
||||
itemId: endpoint.id
|
||||
});
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
"use strict";
|
||||
|
||||
import { debug, normalizeConceptTags } from "../cmap-utils.js";
|
||||
|
||||
let copiedConceptReferences = [];
|
||||
|
||||
export class CmapSelectionController {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
get items() { return this.editor.items; }
|
||||
get connectors() { return this.editor.connectors; }
|
||||
get selectedItem() { return this.editor.selectedItem; }
|
||||
set selectedItem(val) { this.editor.selectedItem = val; }
|
||||
get selectedItems() { return this.editor.selectedItems; }
|
||||
get selectedConnector() { return this.editor.selectedConnector; }
|
||||
set selectedConnector(val) { this.editor.selectedConnector = val; }
|
||||
|
||||
selectItem(record, options = {}) {
|
||||
if (!record) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
const additive = Boolean(options.additive);
|
||||
const toggle = Boolean(options.toggle);
|
||||
const groupRecords = record.groupId && options.expandGroup !== false ?
|
||||
this.items.filter((item) => item.groupId === record.groupId && this.editor.isItemVisible(item)) :
|
||||
[record];
|
||||
debug("selectItem called", {
|
||||
requestedId: record.id,
|
||||
requestedKind: record.kind,
|
||||
additive,
|
||||
groupId: record.groupId,
|
||||
previousIds: this.selectedAll().map((item) => item.id)
|
||||
});
|
||||
|
||||
if (!additive) this.clearSelection(false);
|
||||
const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item));
|
||||
for (const item of groupRecords) {
|
||||
if (remove) {
|
||||
this.selectedItems.delete(item);
|
||||
} else {
|
||||
this.selectedItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record;
|
||||
this.selectedConnector = null;
|
||||
this.refreshSelectionDecoration();
|
||||
debug("selection applied", {
|
||||
selectedId: this.selectedItem ? this.selectedItem.id : null,
|
||||
selectedIds: this.selectedAll().map((item) => item.id),
|
||||
selectionCount: this.selectedItems.size
|
||||
});
|
||||
this.notifySelection();
|
||||
}
|
||||
|
||||
refreshSelectionDecoration() {
|
||||
for (const item of this.items) {
|
||||
const element = item.node.element();
|
||||
const selected = this.selectedItems.has(item);
|
||||
if (element) {
|
||||
element.classList.toggle("rw-cmap-selected", selected);
|
||||
element.classList.toggle(
|
||||
"rw-cmap-selected-primary", selected && item === this.selectedItem);
|
||||
if (selected) {
|
||||
element.setAttribute("aria-selected", "true");
|
||||
item.node.toFront();
|
||||
} else {
|
||||
element.removeAttribute("aria-selected");
|
||||
}
|
||||
this.editor.removeHandles(element);
|
||||
if (selected && item === this.selectedItem) this.editor.ensureHandles(item, element);
|
||||
}
|
||||
if (item.kind === "submap") this.editor.updateSubmapFrame(item);
|
||||
}
|
||||
this.editor.submaps.refreshGroupSelection();
|
||||
}
|
||||
|
||||
selectConnector(record) {
|
||||
this.clearSelection();
|
||||
this.selectedConnector = record;
|
||||
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
|
||||
record.link.redraw();
|
||||
this.notifySelection();
|
||||
}
|
||||
|
||||
clearSelection(notify = true) {
|
||||
const clearedItemIds = this.selectedAll().map((item) => item.id);
|
||||
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
|
||||
for (const item of this.selectedItems) {
|
||||
const element = item.node.element();
|
||||
if (element) {
|
||||
element.classList.remove("rw-cmap-selected");
|
||||
element.classList.remove("rw-cmap-selected-primary");
|
||||
element.removeAttribute("aria-selected");
|
||||
this.editor.removeHandles(element);
|
||||
}
|
||||
}
|
||||
if (this.selectedConnector) {
|
||||
const connector = this.selectedConnector;
|
||||
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
|
||||
connector.link.redraw();
|
||||
}
|
||||
this.selectedItem = null;
|
||||
this.selectedItems.clear();
|
||||
this.selectedConnector = null;
|
||||
if (clearedItemIds.length || clearedConnectorId) {
|
||||
debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId });
|
||||
}
|
||||
if (notify) this.notifySelection();
|
||||
}
|
||||
|
||||
selected() {
|
||||
return this.selectedItem;
|
||||
}
|
||||
|
||||
selectedAll() {
|
||||
return Array.from(this.selectedItems);
|
||||
}
|
||||
|
||||
storeConceptReferences(records) {
|
||||
if (!records.length) return 0;
|
||||
const referencesById = new Map(records.map((source) =>
|
||||
[source.conceptId, {
|
||||
conceptId: source.conceptId,
|
||||
kind: source.kind === "submap" ? "concept" : source.kind,
|
||||
label: source.label,
|
||||
synopsis: source.synopsis,
|
||||
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
|
||||
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
|
||||
descriptionPageSlug: source.descriptionPageSlug,
|
||||
pageSlug: source.pageSlug,
|
||||
cmapSlug: source.cmapSlug,
|
||||
externalUrl: source.externalUrl,
|
||||
imageSource: source.imageSource,
|
||||
width: Number(source.node.attr("width")),
|
||||
height: Number(source.node.attr("height")),
|
||||
backgroundColor: source.backgroundColor,
|
||||
borderColor: source.borderColor,
|
||||
textColor: source.textColor,
|
||||
fontFamily: source.fontFamily,
|
||||
fontSize: source.fontSize,
|
||||
fontWeight: source.fontWeight,
|
||||
fontStyle: source.fontStyle,
|
||||
synopsisTextColor: source.synopsisTextColor,
|
||||
synopsisFontFamily: source.synopsisFontFamily,
|
||||
synopsisFontSize: source.synopsisFontSize,
|
||||
synopsisFontWeight: source.synopsisFontWeight,
|
||||
synopsisFontStyle: source.synopsisFontStyle
|
||||
}]));
|
||||
copiedConceptReferences = Array.from(referencesById.values());
|
||||
return copiedConceptReferences.length;
|
||||
}
|
||||
|
||||
copySelectionReferences() {
|
||||
const selected = this.selectedAll()
|
||||
.filter((item) => item.conceptId && item.kind !== "phrase");
|
||||
const copied = this.storeConceptReferences(selected);
|
||||
if (!copied) return 0;
|
||||
this.notifySelection();
|
||||
return copied;
|
||||
}
|
||||
|
||||
canCutSelectionReferences() {
|
||||
return this.selectedAll().some((item) =>
|
||||
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
|
||||
}
|
||||
|
||||
cutSelectionReferences() {
|
||||
const cuttable = this.selectedAll().filter((item) =>
|
||||
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
|
||||
if (!cuttable.length) return 0;
|
||||
const copied = this.storeConceptReferences(cuttable);
|
||||
if (!copied) return 0;
|
||||
this.clearSelection(false);
|
||||
for (const record of cuttable) this.selectedItems.add(record);
|
||||
this.selectedItem = cuttable.at(-1) || null;
|
||||
this.deleteSelection();
|
||||
return copied;
|
||||
}
|
||||
|
||||
canPasteConceptReferences() {
|
||||
return copiedConceptReferences.length > 0;
|
||||
}
|
||||
|
||||
pasteConceptReferences() {
|
||||
const sources = copiedConceptReferences;
|
||||
if (!sources.length) return [];
|
||||
this.clearSelection(false);
|
||||
const parentSubmap = this.editor.activeMapRoot || null;
|
||||
const pasted = sources.map((source, index) => this.editor.addItem({
|
||||
conceptId: source.conceptId,
|
||||
kind: source.kind === "submap" ? "concept" : source.kind,
|
||||
label: source.label,
|
||||
synopsis: source.synopsis,
|
||||
aspects: source.aspects,
|
||||
tags: source.tags,
|
||||
descriptionPageSlug: source.descriptionPageSlug,
|
||||
pageSlug: source.pageSlug,
|
||||
cmapSlug: source.cmapSlug,
|
||||
externalUrl: source.externalUrl,
|
||||
parentCmapLink: false,
|
||||
imageSource: source.imageSource,
|
||||
parentSubmap,
|
||||
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
|
||||
x: 120 + (index * 36),
|
||||
y: 120 + (index * 36),
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
backgroundColor: source.backgroundColor,
|
||||
borderColor: source.borderColor,
|
||||
textColor: source.textColor,
|
||||
fontFamily: source.fontFamily,
|
||||
fontSize: source.fontSize,
|
||||
fontWeight: source.fontWeight,
|
||||
fontStyle: source.fontStyle,
|
||||
synopsisTextColor: source.synopsisTextColor,
|
||||
synopsisFontFamily: source.synopsisFontFamily,
|
||||
synopsisFontSize: source.synopsisFontSize,
|
||||
synopsisFontWeight: source.synopsisFontWeight,
|
||||
synopsisFontStyle: source.synopsisFontStyle
|
||||
}));
|
||||
for (const record of pasted) this.selectedItems.add(record);
|
||||
this.selectedItem = pasted.at(-1) || null;
|
||||
this.refreshSelectionDecoration();
|
||||
this.notifySelection();
|
||||
return pasted;
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
this.clearSelection(false);
|
||||
for (const item of this.items) {
|
||||
if (this.editor.isEffectiveItemVisible(item)) this.selectedItems.add(item);
|
||||
}
|
||||
this.selectedItem = this.selectedAll().at(-1) || null;
|
||||
this.refreshSelectionDecoration();
|
||||
this.notifySelection();
|
||||
return this.selectedAll();
|
||||
}
|
||||
|
||||
layoutSelectionRecords() {
|
||||
return this.selectedAll().filter((item) => this.editor.isEffectiveItemVisible(item));
|
||||
}
|
||||
|
||||
canLayoutSelection(command) {
|
||||
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
|
||||
return this.layoutSelectionRecords().length >= minimum;
|
||||
}
|
||||
|
||||
applySelectionLayout(command) {
|
||||
const records = this.layoutSelectionRecords();
|
||||
if (!this.canLayoutSelection(command)) return false;
|
||||
const boxes = records.map((record) => ({
|
||||
record,
|
||||
x: Number(record.node.attr("x")),
|
||||
y: Number(record.node.attr("y")),
|
||||
width: Number(record.node.attr("width")),
|
||||
height: Number(record.node.attr("height"))
|
||||
}));
|
||||
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
|
||||
const updates = new Map(boxes.map(({ record }) => [record, {}]));
|
||||
const referenceRight = reference.x + reference.width;
|
||||
const referenceCenter = reference.x + (reference.width / 2);
|
||||
const referenceBottom = reference.y + reference.height;
|
||||
const referenceMiddle = reference.y + (reference.height / 2);
|
||||
|
||||
if (["same-width", "same-size"].includes(command)) {
|
||||
for (const box of boxes) updates.get(box.record).width = reference.width;
|
||||
}
|
||||
if (["same-height", "same-size"].includes(command)) {
|
||||
for (const box of boxes) updates.get(box.record).height = reference.height;
|
||||
}
|
||||
if (command === "align-left") {
|
||||
for (const box of boxes) updates.get(box.record).x = reference.x;
|
||||
}
|
||||
if (command === "align-right") {
|
||||
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
|
||||
}
|
||||
if (command === "align-center") {
|
||||
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
|
||||
}
|
||||
if (command === "align-top") {
|
||||
for (const box of boxes) updates.get(box.record).y = reference.y;
|
||||
}
|
||||
if (command === "align-bottom") {
|
||||
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
|
||||
}
|
||||
if (command === "align-middle") {
|
||||
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
|
||||
}
|
||||
if (command === "distribute-horizontal") {
|
||||
const ordered = [...boxes].sort((a, b) =>
|
||||
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
|
||||
String(a.record.id).localeCompare(String(b.record.id)));
|
||||
const distributionLeft = ordered[0].x;
|
||||
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
|
||||
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
|
||||
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
|
||||
let cursor = distributionLeft;
|
||||
for (const box of ordered) {
|
||||
updates.get(box.record).x = cursor;
|
||||
cursor += box.width + gap;
|
||||
}
|
||||
}
|
||||
if (command === "distribute-vertical") {
|
||||
const ordered = [...boxes].sort((a, b) =>
|
||||
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
|
||||
String(a.record.id).localeCompare(String(b.record.id)));
|
||||
const distributionTop = ordered[0].y;
|
||||
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
|
||||
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
|
||||
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
|
||||
let cursor = distributionTop;
|
||||
for (const box of ordered) {
|
||||
updates.get(box.record).y = cursor;
|
||||
cursor += box.height + gap;
|
||||
}
|
||||
}
|
||||
|
||||
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
|
||||
"align-center", "align-top", "align-bottom", "align-middle",
|
||||
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
|
||||
|
||||
this.editor.scheduleHistoryCommit();
|
||||
for (const record of records) {
|
||||
const attributes = updates.get(record);
|
||||
if (attributes.width !== undefined) {
|
||||
record.width = attributes.width;
|
||||
record.autoWidth = false;
|
||||
}
|
||||
if (attributes.height !== undefined) {
|
||||
record.height = attributes.height;
|
||||
record.autoHeight = false;
|
||||
}
|
||||
record.node.attr(attributes);
|
||||
record.node.redraw();
|
||||
}
|
||||
this.editor.saveCurrentContextLayout();
|
||||
this.editor.refreshConnectorGeometry();
|
||||
for (const submap of this.items
|
||||
.filter((item) => item.kind === "submap")
|
||||
.sort((a, b) => b.submapDepth - a.submapDepth)) {
|
||||
this.editor.updateSubmapFrame(submap);
|
||||
}
|
||||
this.refreshSelectionDecoration();
|
||||
debug("selection layout applied", {
|
||||
command,
|
||||
referenceItemId: reference.record.id,
|
||||
itemIds: records.map((record) => record.id)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
canGroupSelection() {
|
||||
const selected = this.selectedAll();
|
||||
return selected.length >= 2 &&
|
||||
selected.every((item) => item.parentSubmap === selected[0].parentSubmap);
|
||||
}
|
||||
|
||||
groupSelection(options = {}) {
|
||||
const selected = this.selectedAll();
|
||||
if (!this.canGroupSelection()) return false;
|
||||
const parentSubmap = selected[0].parentSubmap;
|
||||
const left = Math.min(...selected.map((item) => Number(item.node.attr("x"))));
|
||||
const top = Math.min(...selected.map((item) => Number(item.node.attr("y"))));
|
||||
const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap";
|
||||
const submap = this.editor.addItem({
|
||||
...options,
|
||||
kind: "submap",
|
||||
label,
|
||||
childMap: options.childMap || label,
|
||||
synopsis: options.synopsis || "Grouped sub-concept map.",
|
||||
parentSubmap,
|
||||
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
|
||||
x: (options.x === undefined || options.x === null) ? left : Number(options.x),
|
||||
y: (options.y === undefined || options.y === null) ? Math.max(20, top - 105) : Number(options.y),
|
||||
backgroundColor: options.backgroundColor || "#edf7e8",
|
||||
borderColor: options.borderColor || "#57834a"
|
||||
});
|
||||
|
||||
submap.expanded = true;
|
||||
submap.submapInitialized = true;
|
||||
for (const item of selected) {
|
||||
item.groupId = null;
|
||||
item.parentSubmap = submap;
|
||||
this.editor.updateSubmapDepth(item, submap.submapDepth + 1);
|
||||
}
|
||||
this.editor.reconcilePhraseMembership();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
this.editor.applyCurrentContextLayout();
|
||||
this.selectItem(submap);
|
||||
debug("selection grouped as submap", {
|
||||
submapId: submap.id,
|
||||
itemIds: selected.map((item) => item.id)
|
||||
});
|
||||
return submap;
|
||||
}
|
||||
|
||||
canUngroupSelection() {
|
||||
return this.selectedAll().some((item) =>
|
||||
Boolean(item.groupId) ||
|
||||
(item.kind === "submap" && !item.separateMap) ||
|
||||
Boolean(item.parentSubmap && item.parentSubmap !== this.editor.activeMapRoot));
|
||||
}
|
||||
|
||||
ungroupSelection() {
|
||||
const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean));
|
||||
const affected = this.items.filter((item) => groupIds.has(item.groupId));
|
||||
for (const item of affected) item.groupId = null;
|
||||
|
||||
const selected = this.selectedAll();
|
||||
const selectedSubmaps = new Set(selected.filter((item) =>
|
||||
item.kind === "submap" && !item.separateMap));
|
||||
const liftedChildren = new Set();
|
||||
for (const submap of selectedSubmaps) {
|
||||
const parent = submap.parentSubmap;
|
||||
const children = this.items.filter((item) => item.parentSubmap === submap);
|
||||
for (const child of children) {
|
||||
liftedChildren.add(child);
|
||||
child.parentSubmap = parent;
|
||||
this.editor.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
|
||||
}
|
||||
submap.expanded = false;
|
||||
submap.submapInitialized = false;
|
||||
submap.childMap = null;
|
||||
this.editor.updateItem(submap, { kind: "concept" });
|
||||
affected.push(submap, ...children);
|
||||
}
|
||||
|
||||
for (const item of selected) {
|
||||
if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap ||
|
||||
item.parentSubmap === this.editor.activeMapRoot) continue;
|
||||
const parent = item.parentSubmap.parentSubmap;
|
||||
item.parentSubmap = parent;
|
||||
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
|
||||
affected.push(item);
|
||||
}
|
||||
|
||||
if (!affected.length) return false;
|
||||
this.editor.reconcilePhraseMembership();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.refreshSelectionDecoration();
|
||||
debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
|
||||
this.notifySelection();
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteSelection() {
|
||||
const records = new Set(this.selectedAll().filter((item) => item !== this.editor.activeMapRoot));
|
||||
for (const record of Array.from(records)) {
|
||||
if (record.kind === "submap") {
|
||||
for (const item of this.items) {
|
||||
if (this.editor.isDescendantOf(item, record)) records.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
const connectors = new Set(this.connectors.filter((connector) =>
|
||||
connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target)));
|
||||
const affectedPhrases = new Set();
|
||||
for (const connector of connectors) {
|
||||
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
|
||||
affectedPhrases.add(connector.source);
|
||||
}
|
||||
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
|
||||
affectedPhrases.add(connector.target);
|
||||
}
|
||||
}
|
||||
let foundOrphan = true;
|
||||
while (foundOrphan) {
|
||||
foundOrphan = false;
|
||||
const remainingConnectors = this.connectors.filter((connector) =>
|
||||
!connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target));
|
||||
for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) {
|
||||
const hasSource = remainingConnectors.some((connector) => connector.target === phrase);
|
||||
const hasTarget = remainingConnectors.some((connector) => connector.source === phrase);
|
||||
if (hasSource && hasTarget) continue;
|
||||
records.add(phrase);
|
||||
for (const connector of this.connectors) {
|
||||
if (connector.source !== phrase && connector.target !== phrase) continue;
|
||||
connectors.add(connector);
|
||||
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
|
||||
affectedPhrases.add(connector.source);
|
||||
}
|
||||
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
|
||||
affectedPhrases.add(connector.target);
|
||||
}
|
||||
}
|
||||
foundOrphan = true;
|
||||
}
|
||||
}
|
||||
if (!records.size && !connectors.size) return false;
|
||||
|
||||
this.clearSelection(false);
|
||||
for (const connector of connectors) {
|
||||
connector.link.remove();
|
||||
this.editor.model.conceptMap.removeConnector(connector.id);
|
||||
}
|
||||
this.editor.connectors = this.connectors.filter((connector) => !connectors.has(connector));
|
||||
for (const record of records) {
|
||||
if (record.mapReference && record.mapReference.id) this.editor.conceptMaps.delete(record.mapReference.id);
|
||||
record.node.remove();
|
||||
this.editor.model.conceptMap.removeItem(record.id);
|
||||
}
|
||||
if (records.size && this.editor.unresolvedConnectors.length) {
|
||||
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
|
||||
this.editor.unresolvedConnectors = this.editor.unresolvedConnectors.filter((connector) =>
|
||||
!deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
|
||||
}
|
||||
this.editor.items = this.items.filter((item) => !records.has(item));
|
||||
this.editor.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
|
||||
this.editor.reconcilePhraseMembership();
|
||||
this.editor.refreshConceptMapReferences();
|
||||
this.editor.refreshSubmapVisibility();
|
||||
this.notifySelection();
|
||||
debug("selection deleted", {
|
||||
itemIds: Array.from(records).map((item) => item.id),
|
||||
connectorIds: Array.from(connectors).map((connector) => connector.id)
|
||||
});
|
||||
this.editor.scheduleHistoryCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
notifySelection() {
|
||||
if (this.editor.onSelectionChange) {
|
||||
this.editor.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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