refactoring van de cmap structuren bijna compleet
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+36
-32
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/vendor/highlight-github.min.css">
|
||||
<link rel="stylesheet" href="/vendor/diff2html.min.css">
|
||||
<link rel="stylesheet" href="/css/wiki.css?id=__CMAP_CACHE_ID__">
|
||||
<link rel="stylesheet" href="/cmap/cmap.css?id=__CMAP_CACHE_ID__">
|
||||
<link rel="stylesheet" href="/js/cmap/cmap.css?id=__CMAP_CACHE_ID__">
|
||||
</head>
|
||||
<body>
|
||||
<div id="connection-status" class="connection-status hidden" role="status"></div>
|
||||
@@ -555,6 +555,10 @@
|
||||
<dialog id="cmap-metadata-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-metadata-title">
|
||||
<form id="cmap-metadata-form">
|
||||
<h2 id="cmap-metadata-title" data-tr="concept-map-details">CMap details</h2>
|
||||
<label>
|
||||
<span data-tr="concept-map-namespace">Information page namespace</span>
|
||||
<input id="cmap-metadata-namespace" type="text" data-tr-placeholder="concept-map-namespace-placeholder" placeholder="review-zuidas">
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="summary">Summary</span>
|
||||
<textarea id="cmap-metadata-summary" rows="5"></textarea>
|
||||
@@ -633,37 +637,37 @@
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"/cmap/cmap-racket-wiki.js": "/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/cmap-utils.js": "/cmap/cmap-utils.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/cmap.js": "/cmap/cmap.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/controller/cmap-history.js": "/cmap/controller/cmap-history.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/controller/cmap-interaction-controller.js": "/cmap/controller/cmap-interaction-controller.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/controller/cmap-selection-controller.js": "/cmap/controller/cmap-selection-controller.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/controller/cmap-submap-controller.js": "/cmap/controller/cmap-submap-controller.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/diagram-component.js": "/cmap/engine/diagram-component.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/diagram-engine.js": "/cmap/engine/diagram-engine.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/diagram-group.js": "/cmap/engine/diagram-group.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/diagram-link.js": "/cmap/engine/diagram-link.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/diagram-node.js": "/cmap/engine/diagram-node.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/drawing-collections.js": "/cmap/engine/drawing-collections.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/drawing-components.js": "/cmap/engine/drawing-components.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/drawing-core.js": "/cmap/engine/drawing-core.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/drawing-relations.js": "/cmap/engine/drawing-relations.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/drawing-support.js": "/cmap/engine/drawing-support.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/engine/drawing-surface.js": "/cmap/engine/drawing-surface.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/concept-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/appearance.js": "/cmap/model/appearance.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/appearance-repository.js": "/cmap/model/appearance-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/cmap-repository.js": "/cmap/model/cmap-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/interchange.js": "/cmap/model/interchange.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/json-exporter.js": "/cmap/model/json-exporter.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/json-importer.js": "/cmap/model/json-importer.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/markdown-exporter.js": "/cmap/model/markdown-exporter.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/settings-repository.js": "/cmap/model/settings-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/view/appearance-editor.js": "/cmap/view/appearance-editor.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/view/cmap-item-decorator.js": "/cmap/view/cmap-item-decorator.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/cmap-racket-wiki.js": "/js/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/cmap-utils.js": "/js/cmap/cmap-utils.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/cmap-view.js": "/js/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/cmap.js": "/js/cmap/cmap.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/controller/cmap-history.js": "/js/cmap/controller/cmap-history.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/controller/cmap-interaction-controller.js": "/js/cmap/controller/cmap-interaction-controller.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/controller/cmap-selection-controller.js": "/js/cmap/controller/cmap-selection-controller.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/controller/cmap-submap-controller.js": "/js/cmap/controller/cmap-submap-controller.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/diagram-component.js": "/js/cmap/engine/diagram-component.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/diagram-engine.js": "/js/cmap/engine/diagram-engine.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/diagram-group.js": "/js/cmap/engine/diagram-group.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/diagram-link.js": "/js/cmap/engine/diagram-link.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/diagram-node.js": "/js/cmap/engine/diagram-node.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/drawing-collections.js": "/js/cmap/engine/drawing-collections.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/drawing-components.js": "/js/cmap/engine/drawing-components.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/drawing-core.js": "/js/cmap/engine/drawing-core.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/drawing-relations.js": "/js/cmap/engine/drawing-relations.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/drawing-support.js": "/js/cmap/engine/drawing-support.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/engine/drawing-surface.js": "/js/cmap/engine/drawing-surface.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/concept-repository.js": "/js/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/concept-map.js": "/js/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/appearance.js": "/js/cmap/model/appearance.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/appearance-repository.js": "/js/cmap/model/appearance-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/cmap-repository.js": "/js/cmap/model/cmap-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/interchange.js": "/js/cmap/model/interchange.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/json-exporter.js": "/js/cmap/model/json-exporter.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/json-importer.js": "/js/cmap/model/json-importer.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/markdown-exporter.js": "/js/cmap/model/markdown-exporter.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/model/settings-repository.js": "/js/cmap/model/settings-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/view/appearance-editor.js": "/js/cmap/view/appearance-editor.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/cmap/view/cmap-item-decorator.js": "/js/cmap/view/cmap-item-decorator.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/routes.js": "/js/wiki/routes.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/markdown.js": "/js/wiki/markdown.js?id=__CMAP_CACHE_ID__",
|
||||
|
||||
@@ -48,7 +48,7 @@ objects, and CMap state is not persisted in browser storage.
|
||||
`cmap-view.js` owns the canvas and its concrete `DiagramEngine` instance.
|
||||
`view/appearance-editor.js` presents the appearance model in the concept dialog
|
||||
and coordinates explicit style and palette changes with its repository.
|
||||
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
|
||||
The concrete dialog controllers live in `../wiki/cmap/dialogs/`. They own
|
||||
their fields, validation and browser events. The workspace supplies the active
|
||||
CMap and performs editor transitions, while the controllers use repository
|
||||
APIs for stored CMaps and people instead of calling backend routes directly.
|
||||
@@ -144,4 +144,4 @@ for keyboard shortcuts and context-menu state.
|
||||
`cmap.css` contains all CMap presentation and interaction styles.
|
||||
|
||||
The source files deliberately remain separate but form one component and are
|
||||
loaded directly from `/cmap/`; there is no runtime download or vendor patch.
|
||||
loaded directly from `/js/cmap/`; there is no runtime download or vendor patch.
|
||||
@@ -0,0 +1,872 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -414,6 +414,16 @@ body.cmap-mode #main {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.rw-cmap-embed-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rw-cmap-embed-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.rw-cmap-embed > header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -879,27 +889,29 @@ body.cmap-mode #main {
|
||||
.rw-cmap-boundary-reference {
|
||||
position: absolute;
|
||||
z-index: 17;
|
||||
min-height: 30px;
|
||||
min-height: 48px;
|
||||
overflow: hidden;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #77838e;
|
||||
border: 1px solid #808080;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #25313c;
|
||||
color: #808080;
|
||||
font-size: 75%;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.rw-cmap-boundary-reference-left::before {
|
||||
content: "← ";
|
||||
content: "";
|
||||
}
|
||||
|
||||
.rw-cmap-boundary-reference-right::after {
|
||||
content: " →";
|
||||
content: "";
|
||||
}
|
||||
|
||||
.rw-cmap-selected {
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,10 @@ export class CmapHistory {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
hasPendingCommit() {
|
||||
return this.timer !== null;
|
||||
}
|
||||
|
||||
get undoCount() {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
-18
@@ -53,7 +53,6 @@ export class CmapInteractionController {
|
||||
}
|
||||
groupItems = Array.from(new Set(expanded));
|
||||
}
|
||||
groupItems = this.includeLinkedMapItems(groupItems);
|
||||
record.moveMembership = {
|
||||
parent: record.parentSubmap,
|
||||
parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null,
|
||||
@@ -68,23 +67,6 @@ export class CmapInteractionController {
|
||||
return record.moveMembership;
|
||||
}
|
||||
|
||||
includeLinkedMapItems(groupItems) {
|
||||
const group = new Set(groupItems);
|
||||
const pending = [...groupItems];
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
for (const connector of this.connectors) {
|
||||
if (connector.source !== current && connector.target !== current) continue;
|
||||
const other = connector.source === current ? connector.target : connector.source;
|
||||
const include = other.kind === "phrase" || Boolean(other.cmapSlug);
|
||||
if (!include || group.has(other)) continue;
|
||||
group.add(other);
|
||||
pending.push(other);
|
||||
}
|
||||
}
|
||||
return Array.from(group);
|
||||
}
|
||||
|
||||
moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
|
||||
const deltaX = x - moveMembership.startX;
|
||||
const deltaY = y - moveMembership.startY;
|
||||
@@ -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,94 @@
|
||||
"use strict";
|
||||
|
||||
function copy(value) {
|
||||
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeConceptName(value) {
|
||||
return String(value || "").trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/** Index lightweight concept placements across stored CMaps. */
|
||||
export class CmapReferenceIndex {
|
||||
constructor() {
|
||||
this.byConceptId = new Map();
|
||||
this.byName = new Map();
|
||||
this.byPage = new Map();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.byConceptId.clear();
|
||||
this.byName.clear();
|
||||
this.byPage.clear();
|
||||
}
|
||||
|
||||
load(placements = [], conceptMaps = [], canonicalPageReference = (value) => value,
|
||||
normalizeName = normalizeConceptName) {
|
||||
this.clear();
|
||||
const mapTitles = new Map(conceptMaps
|
||||
.filter((map) => map && map.slug)
|
||||
.map((map) => [map.slug, map.title || map.slug]));
|
||||
const pageByConcept = new Map();
|
||||
for (const placement of placements) {
|
||||
if (!placement?.conceptId || !placement.pageSlug) continue;
|
||||
pageByConcept.set(placement.conceptId,
|
||||
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
|
||||
}
|
||||
for (const placement of placements) {
|
||||
if (!placement?.conceptId || !placement.cmapSlug) continue;
|
||||
const conceptId = String(placement.conceptId);
|
||||
const slug = String(placement.cmapSlug);
|
||||
const nameKey = normalizeName(placement.label);
|
||||
if (nameKey) this.byName.set(nameKey, conceptId);
|
||||
if (!this.byConceptId.has(conceptId)) this.byConceptId.set(conceptId, new Map());
|
||||
const maps = this.byConceptId.get(conceptId);
|
||||
const existing = maps.get(slug);
|
||||
maps.set(slug, {
|
||||
slug,
|
||||
title: mapTitles.get(slug) || placement.cmapTitle || slug,
|
||||
count: (existing?.count || 0) + (Number(placement.count) || 0)
|
||||
});
|
||||
|
||||
const pageKey = pageByConcept.get(conceptId);
|
||||
if (!pageKey) continue;
|
||||
if (!this.byPage.has(pageKey)) this.byPage.set(pageKey, new Map());
|
||||
const pageConcepts = this.byPage.get(pageKey);
|
||||
if (!pageConcepts.has(conceptId)) {
|
||||
pageConcepts.set(conceptId, {
|
||||
conceptId,
|
||||
label: placement.label || "Concept",
|
||||
count: 0,
|
||||
maps: new Map()
|
||||
});
|
||||
}
|
||||
const concept = pageConcepts.get(conceptId);
|
||||
const count = Number(placement.count) || 0;
|
||||
concept.count += count;
|
||||
const pageMap = concept.maps.get(slug);
|
||||
concept.maps.set(slug, {
|
||||
slug,
|
||||
title: mapTitles.get(slug) || placement.cmapTitle || slug,
|
||||
count: count + (pageMap?.count || 0)
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
mapsFor(conceptId) {
|
||||
return Array.from(this.byConceptId.get(String(conceptId))?.values() || [])
|
||||
.map(copy);
|
||||
}
|
||||
|
||||
countFor(conceptId) {
|
||||
return this.mapsFor(conceptId).reduce((sum, map) => sum + map.count, 0);
|
||||
}
|
||||
|
||||
conceptIdForName(label) {
|
||||
return this.byName.get(normalizeConceptName(label)) || null;
|
||||
}
|
||||
|
||||
pageConcepts(pageReference) {
|
||||
return Array.from(this.byPage.get(String(pageReference).toLocaleLowerCase())?.values() || [])
|
||||
.map((concept) => ({ ...copy(concept), maps: new Map(concept.maps) }));
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export class StoredConceptMap {
|
||||
this._createdAt = record.createdAt || null;
|
||||
this._updatedAt = record.updatedAt || null;
|
||||
this._author = record.author || null;
|
||||
this._renderedSvg = typeof record.renderedSvg === "string" ? record.renderedSvg : "";
|
||||
this._model = model;
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ export class StoredConceptMap {
|
||||
get updatedAt() { return this._updatedAt; }
|
||||
get author() { return this._author; }
|
||||
get model() { return this._model; }
|
||||
get renderedSvg() { return this._renderedSvg; }
|
||||
|
||||
/** Return a detached storage document for comparisons and interchange. */
|
||||
toDocument() {
|
||||
@@ -115,8 +117,12 @@ export class CmapRepository {
|
||||
}
|
||||
|
||||
/** Create a persisted CMap from a domain model. */
|
||||
async create(title, model, slug = null) {
|
||||
const body = { title: String(title).trim(), document: modelDocument(model) };
|
||||
async create(title, model, slug = null, saveInformation = {}) {
|
||||
const body = {
|
||||
title: String(title).trim(),
|
||||
document: modelDocument(model),
|
||||
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
|
||||
};
|
||||
if (slug) body.slug = String(slug);
|
||||
const record = await this.api("/api/cmaps", {
|
||||
method: "POST",
|
||||
@@ -136,7 +142,8 @@ export class CmapRepository {
|
||||
summary: saveInformation.summary || "",
|
||||
saveKind: saveInformation.saveKind || "manual",
|
||||
snapshot: Boolean(saveInformation.snapshot),
|
||||
document: modelDocument(model)
|
||||
document: modelDocument(model),
|
||||
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
|
||||
};
|
||||
const record = await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
|
||||
method: "PUT",
|
||||
@@ -128,6 +128,7 @@ export class ConceptMap {
|
||||
constructor(values = {}) {
|
||||
this.schemaVersion = Number(values.schemaVersion) || 2;
|
||||
this.metadata = {
|
||||
namespace: String(values.metadata?.namespace || "").trim(),
|
||||
tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [],
|
||||
summary: String(values.metadata?.summary || ""),
|
||||
explanationPageSlug: String(values.metadata?.explanationPageSlug || "")
|
||||
@@ -242,6 +243,67 @@ export class CmapModel {
|
||||
return copy(this.conceptMap.derivedView);
|
||||
}
|
||||
|
||||
/** Return connectors that cross the active submap boundary. */
|
||||
boundaryReferencesFor(activeRootId) {
|
||||
if (activeRootId === null || activeRootId === undefined) return [];
|
||||
const rootId = Number(activeRootId);
|
||||
const itemInside = (item) => {
|
||||
if (!item) return false;
|
||||
if (item.id === rootId) return true;
|
||||
let parentId = item.parentSubmapId;
|
||||
while (parentId !== null && parentId !== undefined) {
|
||||
if (Number(parentId) === rootId) return true;
|
||||
parentId = this.conceptMap.item(parentId)?.parentSubmapId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const conceptFor = (item) => item.kind === "phrase" ? null :
|
||||
this.repository.concept(item.conceptId);
|
||||
const placement = (item) => ({
|
||||
id: item.id,
|
||||
x: Number(item.x) || 0,
|
||||
y: Number(item.y) || 0,
|
||||
width: Number(item.width) || 0,
|
||||
height: Number(item.height) || 0
|
||||
});
|
||||
const itemForSide = (item, excludedItem, inside) => {
|
||||
if (item.kind !== "phrase") return item;
|
||||
const adjacent = this.conceptMap.connectors()
|
||||
.filter((candidate) => candidate.sourceId === item.id || candidate.targetId === item.id)
|
||||
.map((candidate) => this.conceptMap.item(
|
||||
candidate.sourceId === item.id ? candidate.targetId : candidate.sourceId))
|
||||
.find((candidate) => candidate && candidate !== excludedItem &&
|
||||
candidate.kind !== "phrase" && itemInside(candidate) === inside);
|
||||
return adjacent || item;
|
||||
};
|
||||
const result = [];
|
||||
for (const connector of this.conceptMap.connectors()) {
|
||||
const source = this.conceptMap.item(connector.sourceId);
|
||||
const target = this.conceptMap.item(connector.targetId);
|
||||
if (!source || !target) continue;
|
||||
const sourceInside = itemInside(source);
|
||||
const targetInside = itemInside(target);
|
||||
if (sourceInside === targetInside) continue;
|
||||
const rawInside = sourceInside ? source : target;
|
||||
const rawOutside = sourceInside ? target : source;
|
||||
const inside = itemForSide(rawInside, rawOutside, true);
|
||||
const outside = itemForSide(rawOutside, rawInside, false);
|
||||
if (!itemInside(inside)) continue;
|
||||
result.push({
|
||||
connector: connector.toDocument(),
|
||||
inside: placement(inside),
|
||||
outside: placement(outside),
|
||||
sourceInside,
|
||||
conceptId: conceptFor(outside)?.id || null,
|
||||
label: conceptFor(outside)?.label || outside.label || "External concept",
|
||||
linkingPhraseLabel: rawInside.kind === "phrase" ? rawInside.label :
|
||||
(rawOutside.kind === "phrase" ? rawOutside.label : null),
|
||||
targetMaps: []
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return a new model with changed map metadata and unchanged contents. */
|
||||
withMetadata(metadata) {
|
||||
const document = this.toDocument();
|
||||
@@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
|
||||
/** Render references from the active diagram to concepts linked from other CMaps. */
|
||||
export class CmapBoundaryReferenceView {
|
||||
constructor({ canvas, zoomFactor, mapTitle, surfaceElement, itemCenter, onOpenReference }) {
|
||||
this.canvas = canvas;
|
||||
this.zoomFactor = zoomFactor;
|
||||
this.mapTitle = mapTitle || "";
|
||||
this.surfaceElement = surfaceElement;
|
||||
this.itemCenter = itemCenter;
|
||||
this.onOpenReference = onOpenReference;
|
||||
this.layer = null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.layer) this.layer.remove();
|
||||
this.layer = null;
|
||||
}
|
||||
|
||||
render(references = []) {
|
||||
this.clear();
|
||||
if (!references.length) return;
|
||||
const surface = this.surfaceElement();
|
||||
if (!surface) return;
|
||||
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "rw-cmap-boundary-layer";
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("rw-cmap-boundary-lines");
|
||||
const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
||||
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
|
||||
marker.setAttribute("id", "rw-cmap-boundary-arrow");
|
||||
marker.setAttribute("viewBox", "0 0 10 10");
|
||||
marker.setAttribute("refX", "9");
|
||||
marker.setAttribute("refY", "5");
|
||||
marker.setAttribute("markerWidth", "7");
|
||||
marker.setAttribute("markerHeight", "7");
|
||||
marker.setAttribute("orient", "auto-start-reverse");
|
||||
const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
|
||||
arrow.setAttribute("fill", "#808080");
|
||||
marker.append(arrow);
|
||||
definitions.append(marker);
|
||||
svg.append(definitions);
|
||||
layer.append(svg);
|
||||
surface.append(layer);
|
||||
this.layer = layer;
|
||||
|
||||
const factor = this.zoomFactor();
|
||||
const viewLeft = this.canvas.scrollLeft / factor;
|
||||
const viewTop = this.canvas.scrollTop / factor;
|
||||
const viewWidth = this.canvas.clientWidth / factor;
|
||||
const viewHeight = this.canvas.clientHeight / factor;
|
||||
const buttonWidth = 190;
|
||||
const occupied = { left: [], right: [] };
|
||||
const reserveY = (side, desired) => {
|
||||
let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40));
|
||||
while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34;
|
||||
if (y > viewTop + viewHeight - 40) y = viewTop + 12;
|
||||
occupied[side].push(y);
|
||||
return y;
|
||||
};
|
||||
|
||||
for (const reference of references) {
|
||||
const inside = this.itemCenter(reference.insideRecord);
|
||||
const side = inside.x < viewLeft + (viewWidth / 2) ? "left" : "right";
|
||||
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
|
||||
const y = reserveY(side, inside.y - 15);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`;
|
||||
button.style.transform = `translate(${x}px, ${y}px)`;
|
||||
button.style.width = `${buttonWidth}px`;
|
||||
if (reference.linkingPhraseLabel) {
|
||||
button.append(document.createTextNode(reference.linkingPhraseLabel));
|
||||
button.append(document.createTextNode(" →"));
|
||||
button.append(document.createElement("br"));
|
||||
}
|
||||
button.append(document.createTextNode(reference.label));
|
||||
if (this.mapTitle) {
|
||||
button.append(document.createElement("br"));
|
||||
const mapLabel = document.createElement("small");
|
||||
const mapName = document.createElement("em");
|
||||
mapName.textContent = `(${this.mapTitle})`;
|
||||
mapLabel.append(mapName);
|
||||
button.append(mapLabel);
|
||||
}
|
||||
button.title = "Open the concept map containing this connection";
|
||||
button.addEventListener("click", () => {
|
||||
if (this.onOpenReference) this.onOpenReference(reference);
|
||||
});
|
||||
layer.append(button);
|
||||
|
||||
const boundaryX = side === "left" ? x + buttonWidth : x;
|
||||
const boundaryY = y + 15;
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const startX = reference.sourceInside ? inside.x : boundaryX;
|
||||
const startY = reference.sourceInside ? inside.y : boundaryY;
|
||||
const endX = reference.sourceInside ? boundaryX : inside.x;
|
||||
const endY = reference.sourceInside ? boundaryY : inside.y;
|
||||
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#808080");
|
||||
path.setAttribute("stroke-width", String(reference.connector.lineWidth || 2));
|
||||
if (reference.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
|
||||
svg.append(path);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
|
||||
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
|
||||
/**
|
||||
* Convert the rendered CMap surface into a standalone SVG snapshot.
|
||||
*
|
||||
* HTML concept cards remain inside an XHTML foreignObject while connector SVG
|
||||
* elements stay vector based. The renderer reads the current DOM only; it has
|
||||
* no knowledge of the editor model or persistence layer.
|
||||
*/
|
||||
export class CmapSnapshotSvgRenderer {
|
||||
constructor({ stylesheetFilter = (sheet) => Boolean(sheet.href?.includes("cmap.css")) } = {}) {
|
||||
this.stylesheetFilter = stylesheetFilter;
|
||||
}
|
||||
|
||||
render(surface, { width = null, height = null, includeStyles = true } = {}) {
|
||||
if (!surface || typeof surface.cloneNode !== "function") {
|
||||
throw new TypeError("A rendered CMap surface is required");
|
||||
}
|
||||
const dimensions = this.dimensions(surface, width, height);
|
||||
const clone = surface.cloneNode(true);
|
||||
this.removeInteractiveElements(clone);
|
||||
clone.removeAttribute("id");
|
||||
clone.style.zoom = "1";
|
||||
clone.style.transform = "none";
|
||||
clone.style.width = `${dimensions.width}px`;
|
||||
clone.style.height = `${dimensions.height}px`;
|
||||
|
||||
const svg = document.createElementNS(SVG_NAMESPACE, "svg");
|
||||
svg.setAttribute("xmlns", SVG_NAMESPACE);
|
||||
svg.setAttribute("xmlns:xhtml", XHTML_NAMESPACE);
|
||||
svg.setAttribute("version", "1.1");
|
||||
svg.setAttribute("width", String(dimensions.width));
|
||||
svg.setAttribute("height", String(dimensions.height));
|
||||
svg.setAttribute("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`);
|
||||
svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
||||
|
||||
if (includeStyles) {
|
||||
const style = document.createElementNS(SVG_NAMESPACE, "style");
|
||||
style.textContent = this.stylesheetText();
|
||||
svg.append(style);
|
||||
}
|
||||
|
||||
const foreignObject = document.createElementNS(SVG_NAMESPACE, "foreignObject");
|
||||
foreignObject.setAttribute("x", "0");
|
||||
foreignObject.setAttribute("y", "0");
|
||||
foreignObject.setAttribute("width", String(dimensions.width));
|
||||
foreignObject.setAttribute("height", String(dimensions.height));
|
||||
clone.setAttribute("xmlns", XHTML_NAMESPACE);
|
||||
foreignObject.append(clone);
|
||||
svg.append(foreignObject);
|
||||
|
||||
return new XMLSerializer().serializeToString(svg);
|
||||
}
|
||||
|
||||
dimensions(surface, requestedWidth, requestedHeight) {
|
||||
const width = Number(requestedWidth) || Math.ceil(Math.max(
|
||||
surface.scrollWidth || 0,
|
||||
surface.getBoundingClientRect?.().width || 0,
|
||||
...Array.from(surface.children || []).map((child) =>
|
||||
(Number.parseFloat(child.style.left) || 0) + (child.offsetWidth || 0))
|
||||
));
|
||||
const height = Number(requestedHeight) || Math.ceil(Math.max(
|
||||
surface.scrollHeight || 0,
|
||||
surface.getBoundingClientRect?.().height || 0,
|
||||
...Array.from(surface.children || []).map((child) =>
|
||||
(Number.parseFloat(child.style.top) || 0) + (child.offsetHeight || 0))
|
||||
));
|
||||
return {
|
||||
width: Math.max(1, width || 1),
|
||||
height: Math.max(1, height || 1)
|
||||
};
|
||||
}
|
||||
|
||||
removeInteractiveElements(root) {
|
||||
for (const element of root.querySelectorAll(
|
||||
".rw-cmap-handle, .rw-cmap-resize-handle, .rw-cmap-relation-handle, " +
|
||||
".rw-cmap-edit-handle, .rw-cmap-submap-toggle, .rw-cmap-submap-open")) {
|
||||
element.remove();
|
||||
}
|
||||
for (const element of root.querySelectorAll("[aria-selected], [data-rw-cmap-bound]") ) {
|
||||
element.removeAttribute("aria-selected");
|
||||
element.removeAttribute("data-rw-cmap-bound");
|
||||
}
|
||||
}
|
||||
|
||||
stylesheetText() {
|
||||
if (typeof document === "undefined") return "";
|
||||
const rules = [];
|
||||
for (const sheet of Array.from(document.styleSheets || [])) {
|
||||
if (!this.stylesheetFilter(sheet)) continue;
|
||||
try {
|
||||
rules.push(...Array.from(sheet.cssRules || []).map((rule) => rule.cssText));
|
||||
} catch (_error) {
|
||||
// Cross-origin stylesheets cannot be inspected and are skipped.
|
||||
}
|
||||
}
|
||||
return rules.join("\n");
|
||||
}
|
||||
}
|
||||
+25
-5
@@ -1,4 +1,4 @@
|
||||
import "/cmap/cmap-racket-wiki.js";
|
||||
import "/js/cmap/cmap-racket-wiki.js";
|
||||
|
||||
import {
|
||||
newPageReference,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
expandNamespacedMarkdownLinks,
|
||||
expandTodoMarkup,
|
||||
expandWikiMentions,
|
||||
protectCamelCaseWikiWords,
|
||||
extractCmapEmbeds,
|
||||
restoreCmapEmbeds
|
||||
} from "./wiki/markdown.js";
|
||||
@@ -24,7 +25,7 @@ import { MailAdmin } from "./wiki/admin/mail-admin.js";
|
||||
import { loadAdminOverview } from "./wiki/admin/overview.js";
|
||||
import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
|
||||
import { UserAdmin } from "./wiki/admin/user-admin.js";
|
||||
import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
import { CmapWorkspaceController } from "./wiki/cmap/cmap-workspace-controller.js";
|
||||
import { ComboBox } from "./widgets/combobox.js";
|
||||
|
||||
(() => {
|
||||
@@ -47,6 +48,7 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
currentPage: null,
|
||||
editingNew: false,
|
||||
newPageSlug: null,
|
||||
newPageSuggestedTitle: "",
|
||||
previousView: "page-view",
|
||||
translations: {},
|
||||
translationPage: "wiki-translations",
|
||||
@@ -77,7 +79,7 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
|
||||
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
|
||||
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
|
||||
const cmapWorkspace = new CmapWorkspace(
|
||||
const cmapWorkspace = new CmapWorkspaceController(
|
||||
state,
|
||||
api,
|
||||
tr,
|
||||
@@ -434,6 +436,16 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
easyMDE.codemirror.refresh();
|
||||
}
|
||||
|
||||
function protectCamelCaseInPage() {
|
||||
if (!easyMDE) return;
|
||||
const source = easyMDE.value();
|
||||
const protectedMarkdown = protectCamelCaseWikiWords(source);
|
||||
if (protectedMarkdown === source) return;
|
||||
easyMDE.value(protectedMarkdown);
|
||||
renderEditorToc();
|
||||
$("save-status").textContent = tr("camelcase-protected", "CamelCase links protected; save the page to keep the changes.");
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Configure the single EasyMDE instance used for page editing.
|
||||
* pre : Vendor scripts and the editor textarea are loaded.
|
||||
@@ -504,6 +516,7 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
console.error(error);
|
||||
});
|
||||
}, "share-2", tr("link-concept-map", "Link to CMap")),
|
||||
toolbarButton("protect-camelcase", protectCamelCaseInPage, "ban", tr("protect-camelcase", "Protect CamelCase")),
|
||||
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", tr("upload-image", "Upload image")),
|
||||
toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")),
|
||||
toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")),
|
||||
@@ -976,12 +989,13 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
* pre : requestedSlug is null or a compact root/namespaced page reference.
|
||||
* post : Namespace, title, template and Markdown controls are initialized for creation.
|
||||
*/
|
||||
function beginNewPage(requestedSlug = null) {
|
||||
function beginNewPage(requestedSlug = null, suggestedTitle = "") {
|
||||
state.editingNew = true;
|
||||
state.currentPage = null;
|
||||
state.newPageSlug = requestedSlug;
|
||||
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
||||
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
|
||||
$("editor-title").value = translationPage ? tr("translations", "Translations") :
|
||||
(suggestedTitle || "");
|
||||
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
|
||||
$("editor-namespace").disabled = Boolean(translationPage);
|
||||
$("editor-tags").value = "";
|
||||
@@ -1580,6 +1594,12 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
}
|
||||
|
||||
function showMissingEditablePage(slug) {
|
||||
const suggestedTitle = state.newPageSuggestedTitle;
|
||||
state.newPageSuggestedTitle = "";
|
||||
if (suggestedTitle) {
|
||||
beginNewPage(slug, suggestedTitle);
|
||||
return;
|
||||
}
|
||||
state.currentPage = null;
|
||||
state.editingNew = false;
|
||||
state.newPageSlug = slug;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
|
||||
import { escapeHtml } from "../../cmap/cmap-utils.js";
|
||||
|
||||
/** Render wiki-specific HTML content for CMap editor items. */
|
||||
export class CmapEditorPresentation {
|
||||
constructor({ state, translate, canonicalPageReference, referenceIndex }) {
|
||||
this.state = state;
|
||||
this.translate = translate;
|
||||
this.canonicalPageReference = canonicalPageReference;
|
||||
this.referenceIndex = referenceIndex;
|
||||
}
|
||||
|
||||
renderItem(record) {
|
||||
const safeLabel = escapeHtml(record.label || "");
|
||||
const safeSynopsis = escapeHtml(record.synopsis || "");
|
||||
const safeKind = escapeHtml(record.kind || "concept");
|
||||
const safeImageSource = escapeHtml(record.imageSource || "");
|
||||
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
|
||||
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
|
||||
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
|
||||
.join("");
|
||||
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
|
||||
const personNames = (Array.isArray(record.tags) ? record.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
|
||||
.map((tag) => String(tag.value));
|
||||
const peopleLabel = personNames.length ?
|
||||
this.translate("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
|
||||
const personTags = personNames.length ?
|
||||
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">👤</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
|
||||
const usageCount = record.conceptId && record.kind !== "phrase" ?
|
||||
this.conceptUsage(record) : null;
|
||||
const usageLabel = usageCount === null ? "" :
|
||||
this.translate("concept-usage-count", "{count} placements across all concept maps")
|
||||
.replace("{count}", String(usageCount));
|
||||
const usage = usageCount === null ? "" :
|
||||
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
|
||||
const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || "");
|
||||
const descriptionExists = Boolean(descriptionReference &&
|
||||
this.state.pages.some((page) => page.slug === descriptionReference));
|
||||
const descriptionButton = record.descriptionPageSlug ?
|
||||
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(this.translate("view-concept-description", "View concept description"))}">I</button>` : "";
|
||||
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
|
||||
const linkedButton = linkedTarget && record.kind !== "submap" ?
|
||||
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
|
||||
const externalButton = record.externalUrl ?
|
||||
`<button type="button" class="rw-cmap-open-external" aria-label="${escapeHtml(this.translate("open-external-web-page", "Open external web page"))}">↗</button>` : "";
|
||||
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
|
||||
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
|
||||
}
|
||||
|
||||
conceptUsage(record) {
|
||||
const maps = this.referenceIndex.byConceptId.get(record.conceptId);
|
||||
const storedTotal = maps ? Array.from(maps.values()).reduce((sum, entry) =>
|
||||
sum + (typeof entry === "number" ? entry : Number(entry.count) || 0), 0) : 0;
|
||||
const editor = this.state.cmapPrototype?.editor;
|
||||
if (!editor || !editor.containsItemRecord(record)) {
|
||||
return Math.max(1, storedTotal || Number(record.usageCount) || 1);
|
||||
}
|
||||
const currentSlug = this.state.currentConceptMapSource?.slug ||
|
||||
this.state.currentConceptMap?.slug || null;
|
||||
const entry = currentSlug && maps ? maps.get(currentSlug) : null;
|
||||
const current = typeof entry === "number" ? entry : Number(entry?.count) || 0;
|
||||
return Math.max(1, storedTotal - current + (Number(record.usageCount) || 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use strict";
|
||||
|
||||
/** Render stored CMap snapshots as linked images inside wiki pages. */
|
||||
export class CmapEmbedView {
|
||||
constructor({ repository, conceptMaps, resolveReference, route, translate }) {
|
||||
this.repository = repository;
|
||||
this.conceptMaps = conceptMaps;
|
||||
this.resolveReference = resolveReference;
|
||||
this.route = route;
|
||||
this.translate = translate;
|
||||
}
|
||||
|
||||
async render(root) {
|
||||
const embeds = Array.from(root.querySelectorAll(
|
||||
".rw-cmap-embed:not([data-cmap-hydrated])"));
|
||||
for (const embed of embeds) await this.renderOne(embed);
|
||||
}
|
||||
|
||||
async renderOne(embed) {
|
||||
embed.dataset.cmapHydrated = "loading";
|
||||
const conceptMap = this.resolveReference(
|
||||
embed.dataset.cmapReference || "", this.conceptMaps());
|
||||
if (!conceptMap) {
|
||||
this.showError(embed, this.translate("concept-map-not-found", "CMap not found"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = await this.repository.load(conceptMap.slug);
|
||||
if (!stored.renderedSvg) {
|
||||
throw new Error("This embedded CMap has no rendered image yet.");
|
||||
}
|
||||
embed.replaceChildren();
|
||||
embed.dataset.cmapSlug = conceptMap.slug;
|
||||
const link = document.createElement("a");
|
||||
link.href = this.route(conceptMap.slug);
|
||||
link.className = "rw-cmap-embed-link";
|
||||
link.title = this.translate("embedded-concept-map-help", "Open this CMap.");
|
||||
const image = document.createElement("img");
|
||||
image.className = "rw-cmap-embed-image";
|
||||
image.alt = conceptMap.title;
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stored.renderedSvg)}`;
|
||||
link.append(image);
|
||||
embed.append(link);
|
||||
embed.dataset.cmapHydrated = "ready";
|
||||
} catch (error) {
|
||||
this.showError(embed, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
showError(embed, message) {
|
||||
embed.dataset.cmapHydrated = "error";
|
||||
embed.replaceChildren();
|
||||
const node = document.createElement("p");
|
||||
node.className = "error";
|
||||
node.textContent = message;
|
||||
embed.append(node);
|
||||
}
|
||||
}
|
||||
+118
-219
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
newPageReference
|
||||
newPageReference,
|
||||
pageReference,
|
||||
splitPageReference
|
||||
} from "../reference.js";
|
||||
import { cmapRoute, pageRoute } from "../routes.js";
|
||||
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
|
||||
import { CmapModel } from "../../../cmap/model/concept-map.js";
|
||||
import { CmapAppearanceRepository } from "../../../cmap/model/appearance-repository.js";
|
||||
import { CmapRepository } from "../../../cmap/model/cmap-repository.js";
|
||||
import { CmapJsonExporter } from "../../../cmap/model/json-exporter.js";
|
||||
import { CmapJsonImporter } from "../../../cmap/model/json-importer.js";
|
||||
import { CmapMarkdownExporter } from "../../../cmap/model/markdown-exporter.js";
|
||||
import { CmapSettingsRepository } from "../../../cmap/model/settings-repository.js";
|
||||
import { PeopleRepository } from "../../../cmap/model/people-repository.js";
|
||||
import { CmapAppearanceEditor } from "../../../cmap/view/appearance-editor.js";
|
||||
import { CmapModel } from "../../cmap/model/concept-map.js";
|
||||
import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js";
|
||||
import { CmapRepository } from "../../cmap/model/cmap-repository.js";
|
||||
import { CmapJsonExporter } from "../../cmap/model/json-exporter.js";
|
||||
import { CmapJsonImporter } from "../../cmap/model/json-importer.js";
|
||||
import { CmapMarkdownExporter } from "../../cmap/model/markdown-exporter.js";
|
||||
import { CmapSettingsRepository } from "../../cmap/model/settings-repository.js";
|
||||
import { CmapReferenceIndex } from "../../cmap/model/cmap-reference-index.js";
|
||||
import { PeopleRepository } from "../../cmap/model/people-repository.js";
|
||||
import { CmapAppearanceEditor } from "../../cmap/view/appearance-editor.js";
|
||||
import { ComboBox } from "../../widgets/combobox.js";
|
||||
import { DescriptionPreview } from "../../widgets/description-preview.js";
|
||||
import { PopupMenu } from "../../widgets/popup-menu.js";
|
||||
@@ -22,6 +25,8 @@ import { CmapHistoryDialog } from "./dialogs/history-dialog.js";
|
||||
import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js";
|
||||
import { CmapPeopleDialog } from "./dialogs/people-dialog.js";
|
||||
import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
|
||||
import { CmapEmbedView } from "./cmap-embed-view.js";
|
||||
import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
|
||||
|
||||
/**
|
||||
* Own the complete browser-side CMap workspace.
|
||||
@@ -30,7 +35,7 @@ import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
|
||||
* persistence, history and browser event handling. The wiki application
|
||||
* supplies only the concrete services needed to cross the module boundary.
|
||||
*/
|
||||
export class CmapWorkspace {
|
||||
export class CmapWorkspaceController {
|
||||
constructor(
|
||||
state,
|
||||
api,
|
||||
@@ -59,6 +64,20 @@ export class CmapWorkspace {
|
||||
const cmapRepository = new CmapRepository(api);
|
||||
const appearanceRepository = new CmapAppearanceRepository(api);
|
||||
const settingsRepository = new CmapSettingsRepository(api);
|
||||
const cmapReferenceIndex = new CmapReferenceIndex();
|
||||
const presentation = new CmapEditorPresentation({
|
||||
state,
|
||||
translate: tr,
|
||||
canonicalPageReference,
|
||||
referenceIndex: cmapReferenceIndex
|
||||
});
|
||||
const embedView = new CmapEmbedView({
|
||||
repository: cmapRepository,
|
||||
conceptMaps: () => state.conceptMaps,
|
||||
resolveReference: cmapMentionTarget,
|
||||
route: cmapRoute,
|
||||
translate: tr
|
||||
});
|
||||
const peopleRepository = new PeopleRepository(api);
|
||||
let appearanceEditor = null;
|
||||
let conceptDialog = null;
|
||||
@@ -110,92 +129,10 @@ export class CmapWorkspace {
|
||||
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
|
||||
cmapEmbedHydrationTimer = window.setTimeout(() => {
|
||||
cmapEmbedHydrationTimer = null;
|
||||
hydrateCmapEmbeds(document).catch((error) => console.error(error));
|
||||
embedView.render(document).catch((error) => console.error(error));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Render all pending CMap embeds below root as read-only CMap editors. */
|
||||
async function hydrateCmapEmbeds(root) {
|
||||
const embeds = Array.from(root.querySelectorAll(
|
||||
".rw-cmap-embed:not([data-cmap-hydrated])"));
|
||||
for (const embed of embeds) {
|
||||
embed.dataset.cmapHydrated = "loading";
|
||||
const conceptMap = cmapMentionTarget(
|
||||
embed.dataset.cmapReference || "",
|
||||
state.conceptMaps);
|
||||
if (!conceptMap) {
|
||||
embed.dataset.cmapHydrated = "error";
|
||||
embed.replaceChildren();
|
||||
const message = document.createElement("p");
|
||||
message.className = "error";
|
||||
message.textContent = tr("concept-map-not-found", "CMap not found");
|
||||
embed.append(message);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stored = await cmapRepository.load(conceptMap.slug);
|
||||
embed.replaceChildren();
|
||||
embed.dataset.cmapSlug = conceptMap.slug;
|
||||
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = conceptMap.title;
|
||||
const hint = document.createElement("span");
|
||||
hint.textContent = tr(
|
||||
"embedded-concept-map-help", "Double-click to open this CMap.");
|
||||
header.append(title, hint);
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "rw-cmap-embed-viewport";
|
||||
const canvas = document.createElement("div");
|
||||
canvas.className =
|
||||
"cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
|
||||
viewport.append(canvas);
|
||||
embed.append(header, viewport);
|
||||
const editor = window.RacketWikiCmap.createEditor(canvas, {
|
||||
renderItem: (record) => cmapNodeHtml(record),
|
||||
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
|
||||
});
|
||||
editor.loadModel(stored.model);
|
||||
const visible = editor.visibleItemRecords();
|
||||
if (visible.length) {
|
||||
const left = Math.min(...visible.map((item) =>
|
||||
Number(item.node.attr("x")))) - 24;
|
||||
const top = Math.min(...visible.map((item) =>
|
||||
Number(item.node.attr("y")))) - 24;
|
||||
const right = Math.max(...visible.map((item) =>
|
||||
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
|
||||
const bottom = Math.max(...visible.map((item) =>
|
||||
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
|
||||
const availableWidth = Math.max(320, embed.clientWidth - 2);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
availableWidth / Math.max(1, right - left),
|
||||
520 / Math.max(1, bottom - top));
|
||||
editor.zoomFactor = scale;
|
||||
editor.map.zoom(scale);
|
||||
viewport.style.height =
|
||||
`${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
|
||||
window.requestAnimationFrame(() => {
|
||||
viewport.scrollLeft = Math.max(0, left * scale);
|
||||
viewport.scrollTop = Math.max(0, top * scale);
|
||||
});
|
||||
}
|
||||
embed.dataset.cmapHydrated = "ready";
|
||||
embed.addEventListener("dblclick", () => {
|
||||
navigateToHash(cmapRoute(conceptMap.slug))
|
||||
.catch((error) => console.error(error));
|
||||
});
|
||||
} catch (error) {
|
||||
embed.dataset.cmapHydrated = "error";
|
||||
embed.replaceChildren();
|
||||
const message = document.createElement("p");
|
||||
message.className = "error";
|
||||
message.textContent = error.message;
|
||||
embed.append(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startCmapSlug() {
|
||||
return settingsRepository.startCmapSlug;
|
||||
}
|
||||
@@ -222,83 +159,14 @@ export class CmapWorkspace {
|
||||
return state.cmapPrototype;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Render one concept card inside an ionstage/cmap node.
|
||||
* pre : record contains label, synopsis and kind.
|
||||
* result : Safe HTML used as the node's content.
|
||||
*/
|
||||
function cmapNodeHtml(record) {
|
||||
const safeLabel = escapeHtml(record.label || "");
|
||||
const safeSynopsis = escapeHtml(record.synopsis || "");
|
||||
const safeKind = escapeHtml(record.kind || "concept");
|
||||
const safeImageSource = escapeHtml(record.imageSource || "");
|
||||
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
|
||||
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
|
||||
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
|
||||
.join("");
|
||||
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
|
||||
const personNames = (Array.isArray(record.tags) ? record.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
|
||||
.map((tag) => String(tag.value));
|
||||
const peopleLabel = personNames.length ?
|
||||
tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
|
||||
const personTags = personNames.length ?
|
||||
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">👤</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
|
||||
const usageCount = record.conceptId && record.kind !== "phrase" ?
|
||||
effectiveCmapConceptUsage(record) : null;
|
||||
const usageLabel = usageCount === null ? "" :
|
||||
tr("concept-usage-count", "{count} placements across all concept maps")
|
||||
.replace("{count}", String(usageCount));
|
||||
const usage = usageCount === null ? "" :
|
||||
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
|
||||
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
|
||||
const descriptionExists = Boolean(descriptionReference &&
|
||||
state.pages.some((page) => page.slug === descriptionReference));
|
||||
const descriptionButton = record.descriptionPageSlug ?
|
||||
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(tr("view-concept-description", "View concept description"))}">I</button>` : "";
|
||||
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
|
||||
const linkedButton = linkedTarget && record.kind !== "submap" ?
|
||||
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
|
||||
const externalButton = record.externalUrl ?
|
||||
`<button type="button" class="rw-cmap-open-external" title="${escapeHtml(tr("open-external-web-page", "Open external web page"))}" aria-label="${escapeHtml(tr("open-external-web-page", "Open external web page"))}">↗</button>` : "";
|
||||
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
|
||||
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
|
||||
return presentation.renderItem(record);
|
||||
}
|
||||
|
||||
function effectiveCmapConceptUsage(record) {
|
||||
const byMap = state.cmapConceptUsage.get(record.conceptId);
|
||||
const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0;
|
||||
const prototype = cmapPrototypeState();
|
||||
const isActiveEditorRecord = Boolean(
|
||||
prototype.editor && prototype.editor.containsItemRecord(record));
|
||||
if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1);
|
||||
const currentSlug = state.currentConceptMapSource?.slug ||
|
||||
state.currentConceptMap?.slug || null;
|
||||
const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0;
|
||||
return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1));
|
||||
return presentation.conceptUsage(record);
|
||||
}
|
||||
|
||||
function hideCmapDescriptionTooltip() {
|
||||
cmapDescriptionPreview.hide();
|
||||
}
|
||||
|
||||
async function showCmapDescriptionTooltip(button) {
|
||||
if (!button.classList.contains("is-filled")) return;
|
||||
const itemElement = button.closest("[data-rw-cmap-item-id]");
|
||||
const prototype = cmapPrototypeState();
|
||||
const record = itemElement && prototype.editor ?
|
||||
prototype.editor.itemRecord(itemElement.dataset.rwCmapItemId) : null;
|
||||
if (!record || !record.descriptionPageSlug) return;
|
||||
|
||||
const reference = canonicalPageReference(record.descriptionPageSlug);
|
||||
await cmapDescriptionPreview.show(button, reference, tr("loading", "Loading…"));
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Derive a compact synopsis from the currently opened wiki page.
|
||||
* pre : state.currentPage may be #f/null when no page is open.
|
||||
* result : Plain text of at most about 150 characters.
|
||||
*/
|
||||
function currentPageSynopsis() {
|
||||
if (!state.currentPage) return "";
|
||||
const container = document.createElement("div");
|
||||
@@ -307,12 +175,6 @@ export class CmapWorkspace {
|
||||
return text.length > 150 ? `${text.slice(0, 147)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Add a concept-like item to the active CMap prototype.
|
||||
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
|
||||
* post : The item is draggable, selectable, resizable and linkable.
|
||||
* result : The item record created by the interaction layer.
|
||||
*/
|
||||
function addCmapPrototypeNode(options = {}) {
|
||||
const prototype = cmapPrototypeState();
|
||||
if (!prototype.editor) return null;
|
||||
@@ -350,10 +212,36 @@ export class CmapWorkspace {
|
||||
|
||||
function openLinkedCmap(record) {
|
||||
if (!record || !record.cmapSlug) return false;
|
||||
rememberActiveCmapContext();
|
||||
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
|
||||
return true;
|
||||
}
|
||||
|
||||
function rememberActiveCmapContext() {
|
||||
const prototype = cmapPrototypeState();
|
||||
const editor = prototype.editor;
|
||||
const mapSlug = state.currentConceptMap?.slug;
|
||||
const root = editor?.activeMapRoot;
|
||||
if (!mapSlug || !root) return;
|
||||
const context = {
|
||||
mapSlug,
|
||||
rootItemId: Number(root.id)
|
||||
};
|
||||
history.replaceState({ ...history.state, cmapContext: context }, "", location.href);
|
||||
}
|
||||
|
||||
async function restoreActiveCmapContext(slug) {
|
||||
const context = history.state?.cmapContext;
|
||||
if (!context || context.mapSlug !== slug) return false;
|
||||
const prototype = cmapPrototypeState();
|
||||
const root = prototype.editor?.itemRecord(context.rootItemId);
|
||||
if (root && root.kind === "submap" && root.separateMap) {
|
||||
prototype.editor.openSubmapMap(root);
|
||||
}
|
||||
history.replaceState({ ...history.state, cmapContext: null }, "", location.href);
|
||||
return true;
|
||||
}
|
||||
|
||||
function openParentCmap() {
|
||||
const source = state.currentConceptMapSource;
|
||||
if (source && source.slug) {
|
||||
@@ -375,7 +263,10 @@ export class CmapWorkspace {
|
||||
|
||||
function currentConceptDialogResources() {
|
||||
const prototype = cmapPrototypeState();
|
||||
const namespaceModel = state.currentConceptMapSource?.model ||
|
||||
state.currentConceptMap?.model;
|
||||
return {
|
||||
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
|
||||
pages: state.pages,
|
||||
conceptMaps: state.conceptMaps,
|
||||
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
|
||||
@@ -745,7 +636,7 @@ export class CmapWorkspace {
|
||||
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
|
||||
cmapStylesheet: Array.from(document.styleSheets)
|
||||
.map((sheet) => sheet.href)
|
||||
.find((href) => href && href.includes("/cmap/cmap.css")) || null
|
||||
.find((href) => href && href.includes("/js/cmap/cmap.css")) || null
|
||||
});
|
||||
|
||||
if (!window.RacketWikiCmap) {
|
||||
@@ -759,9 +650,23 @@ export class CmapWorkspace {
|
||||
const prototype = cmapPrototypeState();
|
||||
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
|
||||
renderItem: (record) => cmapNodeHtml(record),
|
||||
boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
|
||||
state.currentConceptMap?.title || "",
|
||||
onOpenPage: (record) => {
|
||||
if (record.pageSlug) {
|
||||
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
|
||||
const namespace = state.currentConceptMapSource?.model?.metadata()?.namespace ||
|
||||
state.currentConceptMap?.model?.metadata()?.namespace || "";
|
||||
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
|
||||
const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null;
|
||||
const target = namespace && page?.namespace === "cmap" ?
|
||||
pageReference(namespace, page.slug) : record.pageSlug;
|
||||
if (isDescriptionReference && target !== record.pageSlug) {
|
||||
record.pageSlug = target;
|
||||
}
|
||||
if (!state.pages.some((pageRecord) => pageRecord.slug === target)) {
|
||||
state.newPageSuggestedTitle = record.label || "";
|
||||
}
|
||||
navigateToHash(pageRoute(target)).catch((error) => console.error(error));
|
||||
}
|
||||
},
|
||||
onOpenCmap: (record) => {
|
||||
@@ -934,48 +839,14 @@ export class CmapWorkspace {
|
||||
})
|
||||
]);
|
||||
state.conceptMaps = conceptMaps;
|
||||
state.cmapConceptUsage = new Map();
|
||||
state.cmapConceptIdsByName = new Map();
|
||||
state.cmapPageConcepts = new Map();
|
||||
const pageByConcept = new Map();
|
||||
for (const placement of placements) {
|
||||
if (!placement?.conceptId || !placement.pageSlug) continue;
|
||||
pageByConcept.set(
|
||||
placement.conceptId,
|
||||
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
|
||||
}
|
||||
for (const placement of placements) {
|
||||
if (!placement || !placement.conceptId || !placement.cmapSlug) continue;
|
||||
const nameKey = normalizeCmapConceptName(placement.label);
|
||||
if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId);
|
||||
if (!state.cmapConceptUsage.has(placement.conceptId)) {
|
||||
state.cmapConceptUsage.set(placement.conceptId, new Map());
|
||||
}
|
||||
state.cmapConceptUsage.get(placement.conceptId)
|
||||
.set(placement.cmapSlug, Number(placement.count) || 0);
|
||||
|
||||
const pageKey = pageByConcept.get(placement.conceptId);
|
||||
if (!pageKey) continue;
|
||||
if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map());
|
||||
const pageConcepts = state.cmapPageConcepts.get(pageKey);
|
||||
if (!pageConcepts.has(placement.conceptId)) {
|
||||
pageConcepts.set(placement.conceptId, {
|
||||
conceptId: placement.conceptId,
|
||||
label: placement.label || tr("concept", "Concept"),
|
||||
count: 0,
|
||||
maps: new Map()
|
||||
});
|
||||
}
|
||||
const concept = pageConcepts.get(placement.conceptId);
|
||||
const count = Number(placement.count) || 0;
|
||||
concept.count += count;
|
||||
const existingMap = concept.maps.get(placement.cmapSlug);
|
||||
concept.maps.set(placement.cmapSlug, {
|
||||
slug: placement.cmapSlug,
|
||||
title: placement.cmapTitle || placement.cmapSlug,
|
||||
count: count + (existingMap?.count || 0)
|
||||
});
|
||||
}
|
||||
cmapReferenceIndex.load(
|
||||
placements,
|
||||
conceptMaps,
|
||||
canonicalPageReference,
|
||||
normalizeCmapConceptName);
|
||||
state.cmapConceptUsage = cmapReferenceIndex.byConceptId;
|
||||
state.cmapConceptIdsByName = cmapReferenceIndex.byName;
|
||||
state.cmapPageConcepts = cmapReferenceIndex.byPage;
|
||||
renderConceptMapSelector();
|
||||
const prototype = cmapPrototypeState();
|
||||
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
|
||||
@@ -989,6 +860,17 @@ export class CmapWorkspace {
|
||||
return JSON.stringify(prototype.editor.toDocument());
|
||||
}
|
||||
|
||||
function currentCmapRenderedSvg() {
|
||||
const editor = cmapPrototypeState().editor;
|
||||
if (!editor || typeof editor.snapshotSvg !== "function") return "";
|
||||
try {
|
||||
return editor.snapshotSvg();
|
||||
} catch (error) {
|
||||
console.warn("The CMap SVG snapshot could not be created.", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function currentCmapStorageMap() {
|
||||
return state.currentConceptMapSource || state.currentConceptMap;
|
||||
}
|
||||
@@ -1000,8 +882,14 @@ export class CmapWorkspace {
|
||||
function cmapHasUnsavedChanges() {
|
||||
if ($("cmap-view").classList.contains("hidden")) return false;
|
||||
const currentSnapshot = currentCmapSnapshot();
|
||||
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
|
||||
currentSnapshot !== state.cmapSavedSnapshot;
|
||||
if (currentSnapshot === null || state.cmapSavedSnapshot === null) return false;
|
||||
if (currentSnapshot === state.cmapSavedSnapshot) return false;
|
||||
const editor = cmapPrototypeState().editor;
|
||||
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
|
||||
state.cmapSavedSnapshot = currentSnapshot;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelCmapAutosave() {
|
||||
@@ -1249,6 +1137,7 @@ export class CmapWorkspace {
|
||||
if (currentModel?.derivedView()) {
|
||||
const metadata = currentModel.metadata();
|
||||
return {
|
||||
namespace: metadata.namespace || "",
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
summary: metadata.summary || "",
|
||||
explanationPageSlug: metadata.explanationPageSlug || ""
|
||||
@@ -1269,6 +1158,13 @@ export class CmapWorkspace {
|
||||
const conceptMap = state.currentConceptMap;
|
||||
const editor = cmapPrototypeState().editor;
|
||||
if (!conceptMap || !editor) return false;
|
||||
const legacyExplanation = `cmap:${conceptMap.slug}`;
|
||||
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
|
||||
metadata = {
|
||||
...metadata,
|
||||
explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}`
|
||||
};
|
||||
}
|
||||
|
||||
if (conceptMap.model.derivedView()) {
|
||||
const updatedModel = conceptMap.model.withMetadata(metadata);
|
||||
@@ -1430,6 +1326,7 @@ export class CmapWorkspace {
|
||||
}
|
||||
|
||||
const model = prototype.editor.currentModel();
|
||||
const renderedSvg = currentCmapRenderedSvg();
|
||||
const conceptMapAtStart = currentCmapStorageMap();
|
||||
let saveSucceeded = false;
|
||||
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
|
||||
@@ -1442,7 +1339,7 @@ export class CmapWorkspace {
|
||||
showCmapStatus("");
|
||||
return false;
|
||||
}
|
||||
state.currentConceptMap = await cmapRepository.create(title.trim(), model);
|
||||
state.currentConceptMap = await cmapRepository.create(title.trim(), model, null, { renderedSvg });
|
||||
const savedRoute = cmapRoute(state.currentConceptMap.slug);
|
||||
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
|
||||
state.cmapGuardHash = savedRoute;
|
||||
@@ -1456,7 +1353,8 @@ export class CmapWorkspace {
|
||||
tr("automatic-save", "Automatic save") :
|
||||
tr("manual-save", "Manual save")),
|
||||
snapshot: snapshotVersion,
|
||||
saveKind: effectiveHistoryMode
|
||||
saveKind: effectiveHistoryMode,
|
||||
renderedSvg
|
||||
});
|
||||
if (state.currentConceptMapSource &&
|
||||
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
|
||||
@@ -1510,6 +1408,7 @@ export class CmapWorkspace {
|
||||
await loadConceptMaps();
|
||||
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
|
||||
await openStoredConceptMap(requestedSlug);
|
||||
if (await restoreActiveCmapContext(requestedSlug)) markCurrentCmapSaved();
|
||||
} else if (requestedSlug) {
|
||||
state.currentConceptMap = null;
|
||||
state.currentConceptMapSource = null;
|
||||
@@ -46,6 +46,7 @@ export class CmapConceptDialog {
|
||||
open(record, resources) {
|
||||
if (!record || record.kind === "phrase") return;
|
||||
this.record = record;
|
||||
this.resources = resources;
|
||||
this.createContext = null;
|
||||
this.imageSource = record.imageSource || "";
|
||||
this.imageRead = Promise.resolve();
|
||||
@@ -72,6 +73,7 @@ export class CmapConceptDialog {
|
||||
/** Open the dialog for a new concept at the supplied editor context. */
|
||||
openNew(createContext, resources) {
|
||||
this.record = null;
|
||||
this.resources = resources;
|
||||
this.createContext = createContext;
|
||||
this.imageSource = "";
|
||||
this.imageRead = Promise.resolve();
|
||||
@@ -208,7 +210,8 @@ export class CmapConceptDialog {
|
||||
const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page");
|
||||
const descriptionText = descriptionInput.value.trim();
|
||||
const descriptionPage = descriptionText ? newPageReference(descriptionText) :
|
||||
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
|
||||
pageReference(this.resources?.cmapNamespace || "cmap",
|
||||
splitPageReference(newPageReference(label) || "concept").slug);
|
||||
if (!descriptionPage) {
|
||||
this.tabs.select("content");
|
||||
descriptionInput.setCustomValidity(
|
||||
|
||||
@@ -11,6 +11,7 @@ export class CmapMetadataDialog {
|
||||
this.normalizePageReference = normalizePageReference;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.summary = dialog.querySelector("#cmap-metadata-summary");
|
||||
this.namespace = dialog.querySelector("#cmap-metadata-namespace");
|
||||
this.tags = dialog.querySelector("#cmap-metadata-tags");
|
||||
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
|
||||
this.saveHandler = null;
|
||||
@@ -31,6 +32,7 @@ export class CmapMetadataDialog {
|
||||
}
|
||||
|
||||
open(metadata) {
|
||||
this.namespace.value = metadata.namespace || "";
|
||||
this.summary.value = metadata.summary || "";
|
||||
this.tags.value = (metadata.tags || []).join(", ");
|
||||
this.explanationPage.value = metadata.explanationPageSlug || "";
|
||||
@@ -40,6 +42,14 @@ export class CmapMetadataDialog {
|
||||
}
|
||||
|
||||
metadata() {
|
||||
const namespace = this.namespace.value.trim().toLocaleLowerCase();
|
||||
if (namespace && !/^[\p{L}\p{N}][\p{L}\p{N}-]*$/u.test(namespace)) {
|
||||
this.namespace.setCustomValidity(
|
||||
this.tr("invalid-concept-map-namespace", "Use letters, numbers and hyphens for the namespace."));
|
||||
this.namespace.reportValidity();
|
||||
return null;
|
||||
}
|
||||
this.namespace.setCustomValidity("");
|
||||
const explanationInput = this.explanationPage.value.trim();
|
||||
const explanationPageSlug = explanationInput ?
|
||||
this.normalizePageReference(explanationInput) : "";
|
||||
@@ -50,6 +60,7 @@ export class CmapMetadataDialog {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
namespace,
|
||||
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
summary: this.summary.value.trim(),
|
||||
explanationPageSlug
|
||||
|
||||
@@ -182,6 +182,34 @@ function positionIsProtected(start, end, ranges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Prefix every unprotected CamelCase WikiWord with the literal escape marker. */
|
||||
function protectCamelCaseWikiWords(markdown) {
|
||||
const wikiWordPattern = /(?<![!\p{L}\p{N}._-])((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-])/gu;
|
||||
let fence = null;
|
||||
return String(markdown || "").split("\n").map((line) => {
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1].charAt(0);
|
||||
fence = fence === null ? marker : (fence === marker ? null : fence);
|
||||
return line;
|
||||
}
|
||||
if (fence !== null || /^\s{4}/.test(line)) return line;
|
||||
|
||||
const ranges = markdownProtectedRanges(line);
|
||||
const matches = Array.from(line.matchAll(wikiWordPattern));
|
||||
let result = line;
|
||||
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
||||
const match = matches[index];
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (!positionIsProtected(start, end, ranges)) {
|
||||
result = result.slice(0, start) + `!${match[0]}` + result.slice(end);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
|
||||
function literalWikiWord(namespace, wikiWord) {
|
||||
const characters = Array.from(wikiWord);
|
||||
@@ -335,6 +363,7 @@ export {
|
||||
expandNamespacedMarkdownLinks,
|
||||
expandTodoMarkup,
|
||||
expandWikiMentions,
|
||||
protectCamelCaseWikiWords,
|
||||
extractCmapEmbeds,
|
||||
restoreCmapEmbeds
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -18,4 +18,4 @@ Pinned sources:
|
||||
EasyMDE bundles CodeMirror and Marked, so no separate CodeMirror or Markdown renderer is downloaded.
|
||||
|
||||
The CMap implementation is maintained with racket-wiki itself below
|
||||
`static/cmap/`; it is no longer installed as a runtime vendor dependency.
|
||||
`static/js/cmap/`; it is no longer installed as a runtime vendor dependency.
|
||||
|
||||
Reference in New Issue
Block a user