873 lines
35 KiB
JavaScript
873 lines
35 KiB
JavaScript
import { CmapModel } from "./model/concept-map.js";
|
|
import { CmapHistory } from "./controller/cmap-history.js";
|
|
import { CmapSubmapController } from "./controller/cmap-submap-controller.js";
|
|
import { CmapDocumentController } from "./controller/cmap-document-controller.js";
|
|
import { CmapGeometryController } from "./controller/cmap-geometry-controller.js";
|
|
import { CmapRelationController } from "./controller/cmap-relation-controller.js";
|
|
import { CmapLayoutController } from "./controller/cmap-layout-controller.js";
|
|
import { CmapSelectionController } from "./controller/cmap-selection-controller.js";
|
|
import { CmapInteractionController } from "./controller/cmap-interaction-controller.js";
|
|
import { CmapItemDecorator } from "./view/cmap-item-decorator.js";
|
|
import { CmapBoundaryReferenceView } from "./view/cmap-boundary-reference-view.js";
|
|
import { CmapSnapshotSvgRenderer } from "./view/cmap-snapshot-svg-renderer.js";
|
|
import { CmapView } from "./cmap-view.js";
|
|
import {
|
|
debug,
|
|
elementDescription,
|
|
selectionStyle,
|
|
numberOr,
|
|
conceptDescriptionReference,
|
|
newConceptId,
|
|
normalizeConceptTags
|
|
} from "./cmap-utils.js";
|
|
|
|
/*
|
|
* Racket Wiki editor layer for the bundled racket-wiki CMap component.
|
|
*
|
|
* cmap.js owns hit testing, dragging and render lifecycle callbacks. This file
|
|
* adds the wiki-specific editor model and CMapTools-like controls.
|
|
*/
|
|
(() => {
|
|
"use strict";
|
|
|
|
debug("cmap-racket-wiki.js loaded", {
|
|
script: document.currentScript ? document.currentScript.src : null,
|
|
cmapAvailable: true,
|
|
stylesheets: Array.from(document.styleSheets || [])
|
|
.map((sheet) => sheet.href)
|
|
.filter((href) => href && href.includes("cmap.css"))
|
|
});
|
|
|
|
class CmapEditor {
|
|
constructor(canvas, options = {}) {
|
|
this.canvas = canvas;
|
|
this.renderItem = options.renderItem || null;
|
|
this.onOpenPage = options.onOpenPage || null;
|
|
this.onOpenSubMap = options.onOpenSubMap || null;
|
|
this.onPopulateSubMap = options.onPopulateSubMap || null;
|
|
this.onSubMapPromoted = options.onSubMapPromoted || null;
|
|
this.onMapChange = options.onMapChange || null;
|
|
this.onOpenCmap = options.onOpenCmap || null;
|
|
this.onOpenParentCmap = options.onOpenParentCmap || null;
|
|
this.onOpenExternalUrl = options.onOpenExternalUrl || null;
|
|
this.onOpenStoredSubMap = options.onOpenStoredSubMap || null;
|
|
this.onOpenBoundaryReference = options.onOpenBoundaryReference || null;
|
|
this.onVisibilityChange = options.onVisibilityChange || null;
|
|
this.onConfirmDetachFromSubmap = options.onConfirmDetachFromSubmap || null;
|
|
this.onEditItem = options.onEditItem || null;
|
|
this.onCreateConnectedItem = options.onCreateConnectedItem || null;
|
|
this.onSelectionChange = options.onSelectionChange || null;
|
|
this.onHistoryChange = options.onHistoryChange || null;
|
|
this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null;
|
|
this.boundaryReferenceMapTitle = options.boundaryReferenceMapTitle || "";
|
|
this.labels = {
|
|
createRelation: options.createRelationLabel || "Create relation",
|
|
editConcept: options.editConceptLabel || "Edit concept",
|
|
resizeConcept: options.resizeConceptLabel || "Resize concept",
|
|
relation: options.relationLabel || "Relation"
|
|
};
|
|
this.view = new CmapView(
|
|
canvas,
|
|
(component, event) => this.interaction.handleMapSelection(component, event),
|
|
(component, event) => this.interaction.handleMapActivation(component, event),
|
|
options.createDiagramEngine || null);
|
|
this.map = this.view.map;
|
|
this.items = [];
|
|
this.connectors = [];
|
|
this.unresolvedConnectors = [];
|
|
this.model = CmapModel.fromDocument({});
|
|
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
|
this.documentMetadata = this.model.conceptMap.metadata;
|
|
this.activeMapRoot = null;
|
|
this.mapHistory = [];
|
|
this.selectedItem = null;
|
|
this.selectedItems = new Set();
|
|
this.selectedConnector = null;
|
|
this.destroyed = false;
|
|
this.nextId = 1;
|
|
this.nextConnectorId = 1;
|
|
this.nextGroupId = 1;
|
|
this.dragRelation = null;
|
|
this.zoomFactor = 1;
|
|
this.history = new CmapHistory({
|
|
snapshot: () => this.historySnapshot(),
|
|
restore: (snapshot) => this.restoreHistoryDocument(snapshot),
|
|
onChange: (change) => {
|
|
if (this.onHistoryChange) this.onHistoryChange(change);
|
|
}
|
|
});
|
|
this.submaps = new CmapSubmapController(this);
|
|
this.documents = new CmapDocumentController(this);
|
|
this.geometry = new CmapGeometryController(this);
|
|
this.relations = new CmapRelationController(this);
|
|
this.layouts = new CmapLayoutController(this);
|
|
this.selection = new CmapSelectionController(this);
|
|
this.interaction = new CmapInteractionController(this);
|
|
this.decorator = new CmapItemDecorator(this);
|
|
this.boundaryReferenceView = new CmapBoundaryReferenceView({
|
|
canvas,
|
|
zoomFactor: () => this.zoomFactor,
|
|
mapTitle: this.boundaryReferenceMapTitle,
|
|
surfaceElement: () => this.surfaceElement(),
|
|
itemCenter: (record) => this.itemCenter(record),
|
|
onOpenReference: (reference) => {
|
|
if (this.onOpenBoundaryReference) {
|
|
this.onOpenBoundaryReference(reference.outsideRecord, reference.connector);
|
|
}
|
|
}
|
|
});
|
|
this.snapshotRenderer = new CmapSnapshotSvgRenderer();
|
|
this.boundaryScrollHandler = () => this.refreshBoundaryReferences();
|
|
this.canvas.addEventListener("scroll", this.boundaryScrollHandler, { passive: true });
|
|
this.interaction.installCanvasPanning();
|
|
this.interaction.installMarqueeSelection();
|
|
debug("editor created", {
|
|
canvas: elementDescription(canvas),
|
|
drawingEngine: this.map.constructor.name
|
|
});
|
|
}
|
|
|
|
itemRecord(id) {
|
|
return this.items.find((record) => Number(record.id) === Number(id)) || null;
|
|
}
|
|
|
|
itemRecords() {
|
|
return [...this.items];
|
|
}
|
|
|
|
visibleItemRecords() {
|
|
return this.items.filter((record) => this.isEffectiveItemVisible(record));
|
|
}
|
|
|
|
containsItemRecord(record) {
|
|
return this.items.includes(record);
|
|
}
|
|
|
|
descendantItemRecords(record) {
|
|
return this.items.filter((candidate) => this.isDescendantOf(candidate, record));
|
|
}
|
|
|
|
setConceptMapReference(reference) {
|
|
if (!reference?.id) throw new TypeError("A concept-map reference id is required");
|
|
this.conceptMaps.set(reference.id, reference);
|
|
return reference;
|
|
}
|
|
|
|
itemCount() {
|
|
return this.items.length;
|
|
}
|
|
|
|
connectorCount() {
|
|
return this.connectors.length + this.unresolvedConnectors.length;
|
|
}
|
|
|
|
conceptRepository() {
|
|
return this.model.repository;
|
|
}
|
|
|
|
conceptMapModel() {
|
|
return this.model.conceptMap;
|
|
}
|
|
|
|
historySnapshot() {
|
|
return this.documents.historySnapshot();
|
|
}
|
|
|
|
resetHistory() {
|
|
this.history.reset();
|
|
}
|
|
|
|
scheduleHistoryCommit() {
|
|
this.history.scheduleCommit();
|
|
}
|
|
|
|
refreshHistorySnapshot() {
|
|
this.history.refreshSnapshot();
|
|
}
|
|
|
|
commitHistory() {
|
|
return this.history.commit();
|
|
}
|
|
|
|
canUndo() {
|
|
return this.history.canUndo();
|
|
}
|
|
|
|
canRedo() {
|
|
return this.history.canRedo();
|
|
}
|
|
|
|
registerModelItem(record) { return this.documents.registerModelItem(record); }
|
|
bindRecordToModel(record, modelItem) { return this.documents.bindRecordToModel(record, modelItem); }
|
|
attachRecordToModel(record) { return this.documents.attachRecordToModel(record); }
|
|
attachConnectorToModel(record) { return this.documents.attachConnectorToModel(record); }
|
|
synchronizeModel() { return this.documents.synchronizeModel(); }
|
|
clearDocument() { return this.documents.clearDocument(); }
|
|
restoreHistoryDocument(snapshot) { return this.documents.restoreHistoryDocument(snapshot); }
|
|
|
|
undo() {
|
|
const restored = this.history.undo();
|
|
if (!restored) return false;
|
|
debug("undo applied", {
|
|
undoCount: this.history.undoCount,
|
|
redoCount: this.history.redoCount
|
|
});
|
|
return true;
|
|
}
|
|
|
|
redo() {
|
|
const restored = this.history.redo();
|
|
if (!restored) return false;
|
|
debug("redo applied", {
|
|
undoCount: this.history.undoCount,
|
|
redoCount: this.history.redoCount
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* goal : Add a draggable concept or linking-phrase node.
|
|
* pre : options may contain the normal ionstage/cmap node attributes.
|
|
* post : The item is drawn and receives selection/relation/resize UI.
|
|
* result : The item record used by the editor.
|
|
*/
|
|
addItem(options = {}) {
|
|
// Begin the transaction before map.node can synchronously render and fit
|
|
// the item. A render callback may refresh the current layout snapshot;
|
|
// while this timer is pending it must not replace the pre-add snapshot.
|
|
this.scheduleHistoryCommit();
|
|
const requestedId = Number(options.id);
|
|
const id = Number.isInteger(requestedId) && requestedId > 0 ? requestedId : this.nextId;
|
|
this.nextId = Math.max(this.nextId, id + 1);
|
|
const kind = options.kind || "concept";
|
|
const autoWidth = options.width === undefined || options.width === null;
|
|
const autoHeight = options.height === undefined || options.height === null;
|
|
const record = {
|
|
id,
|
|
conceptId: options.conceptId || (kind === "phrase" ? null : newConceptId()),
|
|
kind,
|
|
label: options.label || "Concept",
|
|
synopsis: options.synopsis || "",
|
|
aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [],
|
|
tags: normalizeConceptTags(options.tags),
|
|
descriptionPageSlug: kind === "phrase" ? null :
|
|
(options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)),
|
|
pageSlug: options.pageSlug || null,
|
|
cmapSlug: options.cmapSlug || null,
|
|
externalUrl: options.externalUrl || null,
|
|
parentCmapLink: Boolean(options.parentCmapLink),
|
|
groupId: options.groupId || null,
|
|
childMap: options.childMap || null,
|
|
parentSubmap: options.parentSubmap || null,
|
|
submapDepth: numberOr(options.submapDepth, 0),
|
|
expanded: false,
|
|
submapInitialized: false,
|
|
separateMap: Boolean(options.separateMap),
|
|
mapReference: options.mapReference || null,
|
|
hiddenContexts: new Set(Array.isArray(options.hiddenContexts) ?
|
|
options.hiddenContexts.map(String) : []),
|
|
layouts: options.layouts && typeof options.layouts === "object" ?
|
|
Object.fromEntries(Object.entries(options.layouts).map(([context, layout]) => [context, {
|
|
x: numberOr(Number(layout.x), numberOr(options.x, 0)),
|
|
y: numberOr(Number(layout.y), numberOr(options.y, 0)),
|
|
width: numberOr(Number(layout.width), numberOr(options.width, 220)),
|
|
height: numberOr(Number(layout.height), numberOr(options.height, 70)),
|
|
backgroundColor: layout.backgroundColor || options.backgroundColor || "#f3f6f8",
|
|
borderColor: layout.borderColor || options.borderColor || "#5d6d7e",
|
|
textColor: layout.textColor || options.textColor || "#222222",
|
|
fontFamily: layout.fontFamily || options.fontFamily || "Arial, Helvetica, sans-serif",
|
|
fontSize: layout.fontSize || options.fontSize || "11pt",
|
|
fontWeight: layout.fontWeight || options.fontWeight || "700",
|
|
fontStyle: layout.fontStyle || options.fontStyle || "normal",
|
|
synopsisTextColor: layout.synopsisTextColor || layout.textColor ||
|
|
options.synopsisTextColor || options.textColor || "#222222",
|
|
synopsisFontFamily: layout.synopsisFontFamily || layout.fontFamily ||
|
|
options.synopsisFontFamily || options.fontFamily || "Arial, Helvetica, sans-serif",
|
|
synopsisFontSize: layout.synopsisFontSize ||
|
|
options.synopsisFontSize || "0.84em",
|
|
synopsisFontWeight: layout.synopsisFontWeight || layout.fontWeight ||
|
|
options.synopsisFontWeight || options.fontWeight || "700",
|
|
synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle ||
|
|
options.synopsisFontStyle || options.fontStyle || "normal"
|
|
}])) : {},
|
|
submapAnchorLineElement: null,
|
|
imageSource: options.imageSource || "",
|
|
backgroundColor: options.backgroundColor || "#f3f6f8",
|
|
borderColor: options.borderColor || "#5d6d7e",
|
|
submapBackgroundColor: kind === "submap" ?
|
|
(options.submapBackgroundColor || "#edf7e8") : null,
|
|
submapBorderColor: kind === "submap" ?
|
|
(options.submapBorderColor || "#57834a") : null,
|
|
textColor: options.textColor || "#222222",
|
|
fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif",
|
|
fontSize: options.fontSize || "11pt",
|
|
fontWeight: options.fontWeight || "700",
|
|
fontStyle: options.fontStyle || "normal",
|
|
synopsisTextColor: options.synopsisTextColor || options.textColor || "#222222",
|
|
synopsisFontFamily: options.synopsisFontFamily || options.fontFamily ||
|
|
"Arial, Helvetica, sans-serif",
|
|
synopsisFontSize: options.synopsisFontSize || "0.84em",
|
|
synopsisFontWeight: options.synopsisFontWeight || options.fontWeight || "700",
|
|
synopsisFontStyle: options.synopsisFontStyle || options.fontStyle || "normal",
|
|
width: options.width || (kind === "phrase" ? 145 : 220),
|
|
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
|
|
autoWidth,
|
|
autoHeight,
|
|
fitContentPending: autoWidth || autoHeight,
|
|
node: null
|
|
};
|
|
record.usageCount = record.conceptId && kind !== "phrase" ?
|
|
this.items.filter((item) => item.conceptId === record.conceptId &&
|
|
item.kind !== "phrase").length + 1 : null;
|
|
|
|
const node = this.view.createNode({
|
|
content: this.itemHtml(record),
|
|
contentType: "html",
|
|
x: numberOr(options.x, 80 + ((id * 37) % 420)),
|
|
y: numberOr(options.y, 80 + ((id * 83) % 360)),
|
|
width: record.width,
|
|
height: record.height,
|
|
backgroundColor: record.backgroundColor,
|
|
borderColor: record.borderColor,
|
|
borderWidth: kind === "phrase" ? 0 : 2,
|
|
textColor: record.textColor
|
|
});
|
|
record.node = node;
|
|
this.items.push(record);
|
|
this.documents.attachRecordToModel(record);
|
|
this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []);
|
|
this.refreshConceptMapReferences();
|
|
node.onRendered((_renderedNode, element) => this.decorateItem(record, element));
|
|
node.onMove((_movedNode, x, y) => this.handleItemMove(record, x, y));
|
|
node.onMoveEnd(() => this.handleItemMoveEnd(record));
|
|
debug("item registered; waiting for cmap render callback", {
|
|
id: record.id,
|
|
kind: record.kind,
|
|
label: record.label
|
|
});
|
|
return record;
|
|
}
|
|
|
|
refreshConceptUsageIndicators(conceptIds = null) {
|
|
const ids = conceptIds ? new Set(conceptIds.filter(Boolean)) : new Set(this.items
|
|
.map((item) => item.conceptId).filter(Boolean));
|
|
for (const conceptId of ids) {
|
|
const peers = this.items.filter((item) => item.conceptId === conceptId &&
|
|
item.kind !== "phrase");
|
|
for (const peer of peers) {
|
|
peer.usageCount = peers.length;
|
|
if (!peer.node) continue;
|
|
peer.node.attr({ content: this.itemHtml(peer) });
|
|
peer.node.redraw();
|
|
}
|
|
}
|
|
}
|
|
|
|
addSubmapItem(parentSubmap, options = {}) {
|
|
if (!parentSubmap || parentSubmap.kind !== "submap") {
|
|
throw TypeError("A submap parent is required");
|
|
}
|
|
const index = this.items.filter((item) => item.parentSubmap === parentSubmap).length;
|
|
return this.addItem({
|
|
...options,
|
|
parentSubmap,
|
|
submapDepth: parentSubmap.submapDepth + 1,
|
|
x: options.x === undefined ? Number(parentSubmap.node.attr("x")) + 55 + ((index % 2) * 245) : options.x,
|
|
y: options.y === undefined ? Number(parentSubmap.node.attr("y")) + 105 + (Math.floor(index / 2) * 125) : options.y
|
|
});
|
|
}
|
|
|
|
refreshConceptMapReferences() {
|
|
for (const submap of this.items.filter((item) => item.separateMap && item.mapReference)) {
|
|
submap.mapReference.itemIds = this.items
|
|
.filter((item) => this.isDescendantOf(item, submap))
|
|
.map((item) => item.id);
|
|
}
|
|
}
|
|
|
|
mapContextKey(root = this.activeMapRoot) {
|
|
return this.submaps.mapContextKey(root);
|
|
}
|
|
|
|
itemLayout(record) {
|
|
return this.layouts.itemLayout(record);
|
|
}
|
|
|
|
saveCurrentContextLayout() {
|
|
return this.layouts.saveCurrentContextLayout();
|
|
}
|
|
|
|
applyCurrentContextLayout() {
|
|
return this.layouts.applyCurrentContextLayout();
|
|
}
|
|
|
|
refreshConnectorGeometry() {
|
|
for (const connector of this.connectors) {
|
|
const source = this.connectorEndpoint(connector.source);
|
|
const target = this.connectorEndpoint(connector.target);
|
|
this.applyConnectorVisualEndpoints(connector, source, target);
|
|
if (source && target && source !== target) {
|
|
if (connector.link.sourceNode() !== source.node) connector.link.sourceNode(source.node);
|
|
if (connector.link.targetNode() !== target.node) connector.link.targetNode(target.node);
|
|
connector.visualSource = source;
|
|
connector.visualTarget = target;
|
|
connector.link.straighten();
|
|
connector.link.redraw();
|
|
}
|
|
}
|
|
this.refreshBoundaryReferences();
|
|
}
|
|
|
|
itemInsideActiveMap(record) {
|
|
return this.submaps.itemInsideActiveMap(record);
|
|
}
|
|
|
|
boundaryConceptFor(record, crossedConnector, inside) {
|
|
if (!record || record.kind !== "phrase") return record;
|
|
for (const connector of this.connectors) {
|
|
if (connector === crossedConnector) continue;
|
|
if (connector.source !== record && connector.target !== record) continue;
|
|
const neighbour = connector.source === record ? connector.target : connector.source;
|
|
if (neighbour.kind === "phrase") continue;
|
|
if (this.itemInsideActiveMap(neighbour) === inside) return neighbour;
|
|
}
|
|
return record;
|
|
}
|
|
|
|
refreshBoundaryReferences() {
|
|
this.documents.synchronizeModel();
|
|
const references = this.model.boundaryReferencesFor(this.activeMapRoot?.id)
|
|
.map((reference) => ({
|
|
...reference,
|
|
insideRecord: this.itemRecord(reference.inside.id),
|
|
outsideRecord: this.itemRecord(reference.outside.id)
|
|
}))
|
|
.filter((reference) => reference.insideRecord && reference.outsideRecord);
|
|
this.boundaryReferenceView.render(references);
|
|
}
|
|
|
|
isDescendantOf(record, submap) {
|
|
return this.submaps.isDescendantOf(record, submap);
|
|
}
|
|
|
|
isItemVisible(record) {
|
|
return this.submaps.isItemVisible(record);
|
|
}
|
|
|
|
hiddenItemsInCurrentContext() {
|
|
return this.submaps.hiddenItemsInCurrentContext();
|
|
}
|
|
|
|
canHideSelectionInCurrentContext() {
|
|
return this.submaps.canHideSelectionInCurrentContext();
|
|
}
|
|
|
|
hideSelectionInCurrentContext() {
|
|
return this.submaps.hideSelectionInCurrentContext();
|
|
}
|
|
|
|
showItemInCurrentContext(record) {
|
|
return this.submaps.showItemInCurrentContext(record);
|
|
}
|
|
|
|
ensureSubmapContents(record) {
|
|
return this.submaps.ensureSubmapContents(record);
|
|
}
|
|
|
|
toggleSubmap(record, expanded = !record.expanded) {
|
|
return this.submaps.toggleSubmap(record, expanded);
|
|
}
|
|
|
|
openSubmapMap(record) {
|
|
return this.submaps.openSubmapMap(record);
|
|
}
|
|
|
|
openRootMap() {
|
|
return this.submaps.openRootMap();
|
|
}
|
|
|
|
canStepBackWithinMap() {
|
|
return this.submaps.canStepBackWithinMap();
|
|
}
|
|
|
|
openParentMap() {
|
|
return this.submaps.openParentMap();
|
|
}
|
|
|
|
promoteSubmap(record, name) {
|
|
return this.submaps.promoteSubmap(record, name);
|
|
}
|
|
|
|
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
|
|
return this.submaps.prepareStoredSubmapExtraction(record, targetSlug, childMetadata);
|
|
}
|
|
|
|
replaceDocument(document) { return this.documents.replaceDocument(document); }
|
|
|
|
/** Replace the editor contents with a public CMap domain model. */
|
|
replaceModel(model) { return this.documents.replaceModel(model); }
|
|
|
|
setZoom(percent) {
|
|
const next = Math.max(25, Math.min(300, Number(percent) || 100));
|
|
this.zoomFactor = next / 100;
|
|
this.view.setZoom(this.zoomFactor);
|
|
this.ensureCanvasExtent(0, 0);
|
|
debug("zoom changed", { percent: next, factor: this.zoomFactor });
|
|
return next;
|
|
}
|
|
|
|
zoomPercentage() {
|
|
return Math.round(this.zoomFactor * 100);
|
|
}
|
|
|
|
/**
|
|
* Install a record-level visibility filter on the generic diagram engine.
|
|
*
|
|
* The callback receives a wiki item or connector record and its public
|
|
* diagram handle. It may return a boolean or a `{ visible }` decision.
|
|
* The editor's own visibility rules remain the base visibility and are
|
|
* combined with the supplied policy by DiagramEngine.
|
|
*/
|
|
setFilter(filter) {
|
|
if (filter !== null && filter !== undefined && typeof filter !== "function") {
|
|
throw new TypeError("A CMap filter must be a function");
|
|
}
|
|
this.view.map.setFilter(filter ? (handle) => {
|
|
const item = this.items.find((record) => record.node === handle) || null;
|
|
const connector = this.connectors.find((record) => record.link === handle) || null;
|
|
return filter(item || connector, handle);
|
|
} : null);
|
|
return this;
|
|
}
|
|
|
|
surfaceElement() {
|
|
return this.view.surfaceElement();
|
|
}
|
|
|
|
snapshotSvg(options) {
|
|
return this.snapshotRenderer.render(this.surfaceElement(), options);
|
|
}
|
|
|
|
ensureCanvasExtent(x, y, padding = 180) {
|
|
const surface = this.surfaceElement();
|
|
if (!surface) return;
|
|
const viewportWidth = this.canvas.clientWidth / this.zoomFactor;
|
|
const viewportHeight = this.canvas.clientHeight / this.zoomFactor;
|
|
const width = Math.max(viewportWidth, Number(x) + padding,
|
|
Number.parseFloat(surface.style.minWidth) || 0);
|
|
const height = Math.max(viewportHeight, Number(y) + padding,
|
|
Number.parseFloat(surface.style.minHeight) || 0);
|
|
surface.style.minWidth = `${Math.ceil(width)}px`;
|
|
surface.style.minHeight = `${Math.ceil(height)}px`;
|
|
}
|
|
|
|
/**
|
|
* goal : Change presentation/content of an existing item.
|
|
* pre : record belongs to this editor.
|
|
* post : The ionstage node and interaction handles are redrawn.
|
|
*/
|
|
updateItem(record, changes = {}) {
|
|
// node.redraw may synchronously run fitItemToContent. Mark the mutation
|
|
// first, so that automatic sizing cannot turn the edited state into the
|
|
// history baseline before it has been committed as its own Undo step.
|
|
this.scheduleHistoryCommit();
|
|
if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags);
|
|
for (const [key, value] of Object.entries(changes)) {
|
|
if (value !== undefined) record[key] = value;
|
|
}
|
|
|
|
const identityKeys = ["label", "synopsis", "aspects", "tags",
|
|
"descriptionPageSlug",
|
|
"pageSlug", "cmapSlug", "externalUrl",
|
|
"imageSource"];
|
|
if (record.conceptId && record.kind !== "phrase") {
|
|
for (const peer of this.items) {
|
|
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue;
|
|
for (const key of identityKeys) {
|
|
if (changes[key] !== undefined) peer[key] = changes[key];
|
|
}
|
|
peer.node.attr({ content: this.itemHtml(peer) });
|
|
peer.node.redraw();
|
|
}
|
|
}
|
|
|
|
if (record.autoWidth || record.autoHeight) record.fitContentPending = true;
|
|
|
|
record.width = numberOr(Number(record.width), record.node.attr("width"));
|
|
record.height = numberOr(Number(record.height), record.node.attr("height"));
|
|
record.node.attr({
|
|
content: this.itemHtml(record),
|
|
width: record.width,
|
|
height: record.height,
|
|
backgroundColor: record.backgroundColor,
|
|
borderColor: record.borderColor,
|
|
textColor: record.textColor
|
|
});
|
|
record.node.redraw();
|
|
if (record.kind === "submap") this.submaps.updateGroupAppearance(record);
|
|
this.redrawConnectorsFor(record);
|
|
this.refreshSubmapVisibility();
|
|
}
|
|
|
|
/**
|
|
* goal : Connect source to target with a separate linking phrase.
|
|
* pre : source and target are items in this editor.
|
|
* post : source -> phrase -> target is visible; the phrase can branch.
|
|
* result : The newly created linking-phrase item.
|
|
*/
|
|
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
|
|
return this.relations.connectWithPhrase(source, target, label, editImmediately);
|
|
}
|
|
|
|
submapChain(record) {
|
|
return this.relations.submapChain(record);
|
|
}
|
|
|
|
commonSubmapParent(records) {
|
|
return this.relations.commonSubmapParent(records);
|
|
}
|
|
|
|
reconcilePhraseMembership(phrase = null) {
|
|
return this.relations.reconcilePhraseMembership(phrase);
|
|
}
|
|
|
|
/**
|
|
* goal : Add one directed connector between two existing map items.
|
|
* pre : source and target are items in this editor.
|
|
* post : A selectable ionstage/cmap link joins them.
|
|
* result : Connector record.
|
|
*/
|
|
addConnector(source, target, hasArrow = true, options = {}) {
|
|
return this.relations.addConnector(source, target, hasArrow, options);
|
|
}
|
|
|
|
/**
|
|
* Keep a dragged ionstage/cmap link endpoint and the persisted wiki
|
|
* connector model in lockstep. The drawing library owns the endpoint
|
|
* handles; the wiki model owns sourceId/targetId and submap projection.
|
|
*/
|
|
handleConnectorConnectionChange(connector, type, node) {
|
|
return this.relations.handleConnectorConnectionChange(connector, type, node);
|
|
}
|
|
|
|
// Selection Delegation
|
|
selectItem(record, options) { return this.selection.selectItem(record, options); }
|
|
selectConnector(record) { return this.selection.selectConnector(record); }
|
|
clearSelection(notify) { return this.selection.clearSelection(notify); }
|
|
selected() { return this.selection.selected(); }
|
|
selectedAll() { return this.selection.selectedAll(); }
|
|
refreshSelectionDecoration() { return this.selection.refreshSelectionDecoration(); }
|
|
storeConceptReferences(records) { return this.selection.storeConceptReferences(records); }
|
|
copySelectionReferences() { return this.selection.copySelectionReferences(); }
|
|
canCutSelectionReferences() { return this.selection.canCutSelectionReferences(); }
|
|
cutSelectionReferences() { return this.selection.cutSelectionReferences(); }
|
|
canPasteConceptReferences() { return this.selection.canPasteConceptReferences(); }
|
|
pasteConceptReferences() { return this.selection.pasteConceptReferences(); }
|
|
selectAll() { return this.selection.selectAll(); }
|
|
layoutSelectionRecords() { return this.selection.layoutSelectionRecords(); }
|
|
canLayoutSelection(command) { return this.selection.canLayoutSelection(command); }
|
|
applySelectionLayout(command) { return this.selection.applySelectionLayout(command); }
|
|
canGroupSelection() { return this.selection.canGroupSelection(); }
|
|
groupSelection(options) { return this.selection.groupSelection(options); }
|
|
canUngroupSelection() { return this.selection.canUngroupSelection(); }
|
|
ungroupSelection() { return this.selection.ungroupSelection(); }
|
|
deleteSelection() { return this.selection.deleteSelection(); }
|
|
notifySelection() { return this.selection.notifySelection(); }
|
|
|
|
getDocumentMetadata() {
|
|
return {
|
|
namespace: this.documentMetadata.namespace,
|
|
tags: [...this.documentMetadata.tags],
|
|
summary: this.documentMetadata.summary,
|
|
explanationPageSlug: this.documentMetadata.explanationPageSlug
|
|
};
|
|
}
|
|
|
|
setDocumentMetadata(metadata = {}) {
|
|
this.scheduleHistoryCommit();
|
|
this.documentMetadata = {
|
|
namespace: String(metadata.namespace || "").trim(),
|
|
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
|
|
.map((tag) => tag.trim()).filter(Boolean) : [],
|
|
summary: String(metadata.summary || "").trim(),
|
|
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
|
};
|
|
this.model.conceptMap.metadata = this.documentMetadata;
|
|
return this.getDocumentMetadata();
|
|
}
|
|
|
|
/** Serialize the pure repository and map model at the API boundary. */
|
|
toDocument() { return this.documents.toDocument(); }
|
|
|
|
/** Return the synchronized domain model currently edited by this view. */
|
|
currentModel() { return this.documents.currentModel(); }
|
|
|
|
/** Load a public CMap domain model into an empty editor. */
|
|
loadModel(model) { return this.documents.loadModel(model); }
|
|
loadDocument(document = {}) { return this.documents.loadDocument(document); }
|
|
|
|
// Interaction Delegation
|
|
startRelationDrag(event, source) { return this.interaction.startRelationDrag(event, source); }
|
|
finishRelation(source, target, direct) { return this.interaction.finishRelation(source, target, direct); }
|
|
createDraftLine(start) { return this.interaction.createDraftLine(start); }
|
|
startResize(event, record) { return this.interaction.startResize(event, record); }
|
|
handleItemMove(record, x, y) { return this.interaction.handleItemMove(record, x, y); }
|
|
beginItemMove(record, includeDescendants) { return this.interaction.beginItemMove(record, includeDescendants); }
|
|
moveSubmapGroup(record, x, y, moveMembership) { return this.interaction.moveSubmapGroup(record, x, y, moveMembership); }
|
|
handleItemMoveEnd(record) { return this.interaction.handleItemMoveEnd(record); }
|
|
startSubmapFrameDrag(event, record) { return this.interaction.startSubmapFrameDrag(event, record); }
|
|
installMarqueeSelection() { return this.interaction.installMarqueeSelection(); }
|
|
installCanvasPanning() { return this.interaction.installCanvasPanning(); }
|
|
handleMapSelection(component, event) { return this.interaction.handleMapSelection(component, event); }
|
|
handleMapActivation(component, event) { return this.interaction.handleMapActivation(component, event); }
|
|
|
|
// Decorator Delegation
|
|
itemHtml(record) { return this.decorator.itemHtml(record); }
|
|
editSelected() {
|
|
const record = this.selectedItem;
|
|
if (!record) return false;
|
|
if (record.kind === "phrase") {
|
|
this.editPhraseInline(record);
|
|
} else if (this.onEditItem) {
|
|
this.onEditItem(record);
|
|
}
|
|
return true;
|
|
}
|
|
editPhraseInline(record) { return this.decorator.editPhraseInline(record); }
|
|
applyItemTypography(record, element) { return this.decorator.applyItemTypography(record, element); }
|
|
decorateItem(record, renderedElement) { return this.decorator.decorateItem(record, renderedElement); }
|
|
fitItemToContent(record, element) { return this.decorator.fitItemToContent(record, element); }
|
|
decorateConnector(record, renderedElement) { return this.decorator.decorateConnector(record, renderedElement); }
|
|
ensureSubmapToggle(record, element) { return this.decorator.ensureSubmapToggle(record, element); }
|
|
ensureHandles(record, element) { return this.decorator.ensureHandles(record, element); }
|
|
removeHandles(element) { return this.decorator.removeHandles(element); }
|
|
boundaryConceptFor(record, crossedConnector, inside) { return this.decorator.boundaryConceptFor(record, crossedConnector, inside); }
|
|
updateSubmapAnchorLine(record, bounds, surface) { return this.decorator.updateSubmapAnchorLine(record, bounds, surface); }
|
|
|
|
updateSubmapDepth(record, depth) {
|
|
return this.geometry.updateSubmapDepth(record, depth);
|
|
}
|
|
|
|
pointInBounds(point, bounds) {
|
|
return this.geometry.pointInBounds(point, bounds);
|
|
}
|
|
|
|
pointNearConnector(point, tolerance = 8 / this.zoomFactor) {
|
|
return this.geometry.pointNearConnector(point, tolerance);
|
|
}
|
|
|
|
submapAtPoint(point, excludedRecord = null, excludedRecords = []) {
|
|
return this.geometry.submapAtPoint(point, excludedRecord, excludedRecords);
|
|
}
|
|
|
|
submapBounds(record, excludedRecord = null) {
|
|
return this.geometry.submapBounds(record, excludedRecord);
|
|
}
|
|
|
|
visualEndpointFor(record) {
|
|
return this.geometry.visualEndpointFor(record);
|
|
}
|
|
|
|
isEffectiveItemVisible(record) {
|
|
return this.geometry.isEffectiveItemVisible(record);
|
|
}
|
|
|
|
connectorEndpoint(record) {
|
|
return this.geometry.connectorEndpoint(record);
|
|
}
|
|
|
|
applyConnectorVisualEndpoints(connector, source, target) {
|
|
return this.geometry.applyConnectorVisualEndpoints(connector, source, target);
|
|
}
|
|
|
|
refreshSubmapVisibility() {
|
|
this.submaps.refreshVisibility();
|
|
}
|
|
|
|
updateSubmapFrame(record) {
|
|
this.submaps.refreshGroups();
|
|
return this.submaps.diagramGroups.get(record)?.redraw() || null;
|
|
}
|
|
|
|
redrawConnectorsFor(record) {
|
|
for (const connector of this.connectors) {
|
|
if (connector.source === record || connector.target === record ||
|
|
connector.visualSource === record || connector.visualTarget === record) {
|
|
connector.link.straighten();
|
|
connector.link.redraw();
|
|
}
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.destroyed = true;
|
|
this.history.destroy();
|
|
if (this.boundaryScrollHandler) {
|
|
this.canvas.removeEventListener("scroll", this.boundaryScrollHandler);
|
|
this.boundaryScrollHandler = null;
|
|
}
|
|
this.interaction.destroy();
|
|
this.boundaryReferenceView.destroy();
|
|
for (const item of this.items) {
|
|
if (item.submapAnchorLineElement) {
|
|
item.submapAnchorLineElement.remove();
|
|
item.submapAnchorLineElement = null;
|
|
}
|
|
}
|
|
this.view.destroy();
|
|
debug("editor destroyed");
|
|
}
|
|
|
|
logicalCanvasWidth() {
|
|
return this.geometry.logicalCanvasWidth();
|
|
}
|
|
|
|
logicalCanvasHeight() {
|
|
return this.geometry.logicalCanvasHeight();
|
|
}
|
|
|
|
canvasPoint(event) {
|
|
return this.geometry.canvasPoint(event);
|
|
}
|
|
|
|
itemAt(clientX, clientY) {
|
|
return this.geometry.itemAt(clientX, clientY);
|
|
}
|
|
|
|
itemCenter(record) {
|
|
return this.geometry.itemCenter(record);
|
|
}
|
|
|
|
}
|
|
|
|
let lastEditor = null;
|
|
|
|
window.RacketWikiCmap = {
|
|
version: "0.2.122",
|
|
createEditor(canvas, options) {
|
|
lastEditor = new CmapEditor(canvas, options);
|
|
return lastEditor;
|
|
},
|
|
debugSelection() {
|
|
if (!lastEditor) {
|
|
debug("debugSelection: no editor has been created");
|
|
return null;
|
|
}
|
|
const record = lastEditor.selected();
|
|
const element = record ? record.node.element() : null;
|
|
const result = {
|
|
selectedId: record ? record.id : null,
|
|
selectedIds: lastEditor.selectedAll().map((item) => item.id),
|
|
selectedKind: record ? record.kind : null,
|
|
selectedLabel: record ? record.label : null,
|
|
element: elementDescription(element),
|
|
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
|
|
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
|
|
computedStyle: selectionStyle(element)
|
|
};
|
|
debug("manual selection inspection", result);
|
|
return result;
|
|
}
|
|
};
|
|
})();
|