Files
racket-wiki/static/js/cmap/controller/cmap-document-controller.js
T

334 lines
13 KiB
JavaScript

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