refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
+147
View File
@@ -0,0 +1,147 @@
# Racket Wiki CMap component
This directory contains the complete browser-side CMap component.
`cmap.js` is the ES-module entry point of the diagram engine. Its public
`DiagramEngine`, `DiagramNode` and `DiagramLink` classes expose only the API
used by the wiki editor: creating nodes and links, changing their presentation,
connecting endpoints, zooming and receiving render, selection, activation and
move events. The engine does not install a `window.Cmap` global.
The modules under `engine/` separate that API from the renderer internals.
`diagram-engine.js` owns the surface and component lifetime;
`diagram-node.js`, `diagram-link.js` and `diagram-component.js` define the
public handles. The `drawing-*` modules contain DOM rendering, geometry,
relations, hit testing and pointer interaction. That rendering core is based on
the MIT-licensed ionstage/cmap 0.1.3 source and is maintained as part of
racket-wiki. Activation is recognized inside the same hit-test and drag
lifecycle as selection, so it does not depend on a DOM `dblclick` event that
may be suppressed by dragging.
`diagram-group.js` adds the generic view-level `DiagramGroup` abstraction. A
group contains nodes or nested groups, calculates a frame from their geometry,
and can be expanded or collapsed. It deliberately knows nothing about CMaps,
wiki pages or aspects. `DiagramEngine.setFilter` accepts an application policy
for component visibility; the engine combines that policy with each component's
own visibility and hides links whose endpoints are hidden. A wiki adapter can
therefore translate submap membership or aspect matching into groups and
filters without putting domain rules into the drawing engine. Proxy endpoints
and boundary navigation remain application concerns until a generic endpoint
projection API is introduced.
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
placements, linking phrases, connectors, metadata and presentation values.
`model/cmap-repository.js` owns the server boundary: it decodes stored CMaps,
normalizes legacy concept identities and converts backend documents to and
from `CmapModel`. It is the only browser-side CMap model module that knows the
`/api/cmaps` routes. `model/appearance.js` owns the shared named styles, colour
palette, validation and style matching. `model/appearance-repository.js`
serializes that model through the appearance API. `model/settings-repository.js`
owns the wiki start CMap and the authenticated user's A4-guide and
per-CMap-context zoom settings. `model/people-repository.js` is the server
boundary for the people referenced by concept tags.
The repository objects keep only an in-memory session copy; the backend remains
authoritative. None of these model modules contains DOM or drawing-engine
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 `../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.
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
state, content-based initial sizing, resize and relation controls. Its view
records connect pure model ids to drawing nodes without making those nodes part
of the model. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
their shared name, aspects, people, description page, synopsis, image, external
web address and linked wiki/CMap target, plus placement-specific background
colour and typography. The editor has one compact tab for content, links and
images, and a second tab for detailed colours, typography and submap
presentation. Detailed settings can be saved as named, wiki-wide database
styles; selected styles are applied immediately and non-default styles can be
deleted. Its action bar remains visible while a tab scrolls.
Structural kinds such as a sub-CMap head remain placement roles. Page concepts retain their linked `pageSlug`; double-click is
reserved for opening that wiki page. Concepts may alternatively retain a
`cmapSlug` for another stored CMap or a `parentCmapLink` for navigation back
from a child map. An optional `externalUrl` accepts only HTTP(S), opens in a new
browser tab and is shared by every placement of that concept. Images are data
URLs inside the persisted CMap JSON document.
Font families are selected from a practical list and font sizes are stored in
typographic points. The host can change the linked page or CMap independently
of the visible label and provides background and text-colour pickers.
Linking phrases are compact borderless nodes at the intentional bend between
two connector segments. Their position is stored with the rest of the map.
When an endpoint is deleted, a genuinely branching phrase is retained while it
still has at least one incoming and one outgoing concept. Losing the final
endpoint on either side removes the phrase and all its remaining segments.
Multiple selected items can be moved or deleted together. Grouping is a
structural operation: the editor creates an expanded sub-CMap with a named main
concept and adopts the selection as its contents. Ungrouping dissolves a
selected inline sub-CMap or moves selected child items one level outward.
**Make separate CMap** extracts the descendants into a complete stored CMap.
The former sub-CMap head remains in the parent as an ordinary concept whose
shared `cmapSlug` links to the new map. Ownership from that concept to the
extracted concepts is removed; internal child connectors move with the child,
while relations crossing the boundary terminate on the retained owner concept.
The same action converts an older `derivedView` in place: its slug, title and
metadata remain intact, but its document no longer depends on the parent CMap.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
map references. The public `currentModel`, `loadModel` and `replaceModel`
methods let the workspace pass complete domain models to and from the editor.
The CMap repository, rather than the workspace, persists those models.
## JSON interchange
`model/interchange.js` defines and validates the versioned
`racket-wiki-cmap-bundle` format. `model/json-exporter.js` builds complete bundles
through `CmapRepository` and the supplied page and attachment loaders.
`model/json-importer.js` reads those bundles, stores CMaps through the same
repository and performs the required page and attachment writes. The workspace
decides only how conflicts are presented to the user.
A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
referenced attachments of linked wiki and explanation pages. Attachments retain
their MIME type and binary base64 content; import uploads them under the target
page and rewrites the Markdown to the newly allocated URL. The root CMap and every included CMap retain
their stable slug. Derived CMaps automatically include their source CMap.
Presentation includes coordinates, dimensions, colours, font settings,
sub-CMap membership, linking phrases and connectors. Connector endpoints use
local item ids; concept identity never depends on those local ids. The formal
contract is
[`racket-wiki-cmap-bundle-v1.schema.json`](../schemas/racket-wiki-cmap-bundle-v1.schema.json).
An external generator may use a temporary id such as `new:security-review` for
a new concept. Every concept must also occur in at least one non-phrase
`document.items[]` placement with coordinates. On import the server turns such
a temporary identity into a UUID through the central Racket UUID helper. UUIDs
already present in an export remain unchanged. Imported CMap slugs likewise
remain unchanged.
`model/markdown-exporter.js` reads stored models through `CmapRepository` but
produces a readable
report instead of an importable bundle. It is independent of the JSON
exporter because the report follows linked maps and optional wiki pages for a
different purpose. Additional output formats can therefore be implemented as
separate exporters without adding format-specific code to the workspace.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
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 `/js/cmap/`; there is no runtime download or vendor patch.
+872
View File
@@ -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;
}
};
})();
+93
View File
@@ -0,0 +1,93 @@
"use strict";
export const debugPrefix = "[racket-wiki:cmap 0.2.122]";
export function debug(message, details) {
if (details === undefined) {
console.info(debugPrefix, message);
return;
}
console.info(debugPrefix, message, details);
}
export function elementDescription(element) {
if (!(element instanceof Element)) return String(element);
return {
tag: element.tagName,
id: element.id || null,
classes: Array.from(element.classList),
itemId: element.dataset.rwCmapItemId || null
};
}
export function selectionStyle(element) {
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
const style = window.getComputedStyle(element);
return {
pointerEvents: style.pointerEvents,
outline: style.outline,
outlineOffset: style.outlineOffset,
boxShadow: style.boxShadow,
overflow: style.overflow,
zIndex: style.zIndex
};
}
export function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
export function conceptDescriptionReference(label, id) {
const slug = String(label || "")
.normalize("NFKD")
.toLocaleLowerCase()
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120)
.replace(/-+$/g, "");
return `cmap:${slug || `concept-${id}`}`;
}
export function newConceptId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
try {
return window.crypto.randomUUID().toLowerCase();
} catch (_error) {
// randomUUID can be exposed but forbidden in an insecure/file context.
}
}
const bytes = new Uint8Array(16);
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
window.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
export function normalizeConceptTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
return {
type: String(tag.type || "label").trim() || "label",
value: String(tag.value || tag.name || "").trim()
};
}).filter((tag) => tag && tag.value);
}
+41
View File
@@ -0,0 +1,41 @@
import { DiagramEngine } from "./cmap.js";
/**
* Present a CMap through the bundled drawing engine.
*
* CmapView owns the canvas and concrete cmap.js instance. It creates and
* destroys drawing objects but contains no concept, placement or persistence
* rules; CmapEditor remains responsible for interpreting user interaction.
*/
export class CmapView {
constructor(canvas, onSelection, onActivation, createEngine = null) {
if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required");
if (createEngine !== null && typeof createEngine !== "function") {
throw new TypeError("A diagram-engine factory must be a function");
}
this.canvas = canvas;
this.map = createEngine ? createEngine(canvas) : new DiagramEngine(canvas);
this.map.onSelection(onSelection);
this.map.onActivation(onActivation);
}
createNode(attributes) {
return this.map.node(attributes);
}
createConnector(attributes) {
return this.map.link(attributes);
}
setZoom(factor) {
this.map.zoom(factor);
}
surfaceElement() {
return this.canvas.querySelector(":scope > .rw-cmap-surface");
}
destroy() {
if (typeof this.map.destroy === "function") this.map.destroy();
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
/**
* Public entry point for the racket-wiki diagram engine.
*
* CMap application code imports DiagramEngine explicitly. The Cmap alias is
* retained as an exported name for code that describes the visual surface as
* a CMap, without installing another global on window.
*/
export { DiagramEngine, DiagramEngine as Cmap } from "./engine/diagram-engine.js";
export { DiagramGroup } from "./engine/diagram-group.js";
@@ -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)
};
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Undo/redo history for an editor whose state can be represented as JSON.
*
* The history owns snapshots, stack limits and the asynchronous commit boundary.
* It does not know how a document is rendered or restored: those concerns are
* supplied by the editor through the constructor callbacks.
*/
export class CmapHistory {
/**
* goal : Create a history coordinator for one editor state.
* pre : snapshot and restore are functions for the same serialized state.
* post : The coordinator is ready to track state after reset() is called.
* result : A CmapHistory instance with empty undo and redo stacks.
* internals : The callbacks keep this controller independent of the editor's
* model and view; the stacks contain only serialized snapshots.
*
* @param {object} options History callbacks and configuration.
* @param {Function} options.snapshot Returns the current serialized state.
* @param {Function} options.restore Restores one serialized state.
* @param {Function} [options.onChange] Receives undo/redo availability changes.
* @param {number} [options.limit=100] Maximum number of undo snapshots.
*/
constructor({ snapshot, restore, onChange = null, limit = 100 }) {
if (typeof snapshot !== "function") throw new TypeError("A snapshot function is required");
if (typeof restore !== "function") throw new TypeError("A restore function is required");
this.snapshot = snapshot;
this.restoreDocument = restore;
this.onChange = onChange;
this.limit = Math.max(1, Number(limit) || 100);
this.undoStack = [];
this.redoStack = [];
this.currentSnapshot = null;
this.timer = null;
this.ready = false;
this.isRestoring = false;
}
/** Notify the host about the current availability of undo and redo. */
notify() {
if (this.onChange) {
this.onChange({
canUndo: this.canUndo(),
canRedo: this.canRedo()
});
}
}
/**
* goal : Start a new history session at the current editor state.
* pre : The snapshot callback returns the current serialized state.
* post : Both stacks are empty and the current state is the history baseline.
* result : Undefined; the host is notified of the empty stacks.
* internals : A pending timer is cancelled before the baseline is captured.
*/
reset() {
this.cancelScheduledCommit();
this.undoStack = [];
this.redoStack = [];
this.ready = true;
this.currentSnapshot = this.snapshot();
this.notify();
}
/** Cancel a pending asynchronous history commit. */
cancelScheduledCommit() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
this.timer = null;
}
}
/**
* Schedule one commit for the current mutation transaction.
* The zero-delay timer groups synchronous editor changes into one undo step.
*/
scheduleCommit() {
if (!this.ready || this.isRestoring) return;
this.cancelScheduledCommit();
this.timer = window.setTimeout(() => {
this.timer = null;
this.commit();
}, 0);
}
/** Update the baseline after renderer-only normalization. */
refreshSnapshot() {
if (!this.ready || this.isRestoring || this.timer !== null) return;
this.currentSnapshot = this.snapshot();
}
/**
* Commit the current state when it differs from the baseline.
* @returns {boolean} Whether a new undo step was recorded.
*/
commit() {
if (!this.ready || this.isRestoring) return false;
this.cancelScheduledCommit();
const nextSnapshot = this.snapshot();
if (nextSnapshot === this.currentSnapshot) return false;
if (this.currentSnapshot !== null) {
this.undoStack.push(this.currentSnapshot);
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
this.currentSnapshot = nextSnapshot;
this.redoStack = [];
this.notify();
return true;
}
/** Return whether an undo operation is available. */
canUndo() {
return this.undoStack.length > 0;
}
/** Return whether a redo operation is available. */
canRedo() {
return this.redoStack.length > 0;
}
hasPendingCommit() {
return this.timer !== null;
}
get undoCount() {
return this.undoStack.length;
}
get redoCount() {
return this.redoStack.length;
}
/**
* Restore one snapshot while suppressing history commits caused by loading.
* The editor callback performs the actual model and view reconstruction.
*/
restoreSnapshot(snapshot) {
this.isRestoring = true;
try {
this.restoreDocument(snapshot);
} finally {
this.isRestoring = false;
}
this.currentSnapshot = snapshot;
this.notify();
}
/** Restore the previous committed state, if one exists. */
undo() {
this.commit();
if (!this.canUndo()) return false;
this.redoStack.push(this.currentSnapshot);
const snapshot = this.undoStack.pop();
this.restoreSnapshot(snapshot);
return true;
}
/** Restore the most recently undone state, if one exists. */
redo() {
this.commit();
if (!this.canRedo()) return false;
this.undoStack.push(this.currentSnapshot);
const snapshot = this.redoStack.pop();
this.restoreSnapshot(snapshot);
return true;
}
/**
* goal : Replace the current document as one undoable operation.
* pre : replaceDocument performs the complete document replacement.
* post : The replacement is current and redo history has been discarded.
* result : Undefined; the host receives the new undo/redo availability.
* internals : The old baseline is pushed before the callback runs, while
* isRestoring prevents loading callbacks from creating nested history steps.
*/
replace(replaceDocument) {
this.commit();
const previousSnapshot = this.currentSnapshot || this.snapshot();
this.isRestoring = true;
try {
replaceDocument();
} finally {
this.isRestoring = false;
}
const nextSnapshot = this.snapshot();
if (previousSnapshot !== nextSnapshot) {
this.undoStack.push(previousSnapshot);
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
this.currentSnapshot = nextSnapshot;
this.redoStack = [];
this.notify();
}
/** Release the timer when the owning editor is destroyed. */
destroy() {
this.cancelScheduledCommit();
}
}
@@ -0,0 +1,561 @@
"use strict";
import { debug, elementDescription } from "../cmap-utils.js";
export class CmapInteractionController {
constructor(editor) {
this.editor = editor;
this.marqueeMouseDownHandler = null;
this.activeMarqueeCleanup = null;
this.canvasPanPointerDownHandler = null;
this.activeCanvasPanCleanup = null;
this.boundaryRefreshScheduled = false;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get canvas() { return this.editor.canvas; }
get zoomFactor() { return this.editor.zoomFactor; }
handleItemMove(record, x, y) {
const movesCompleteSubmap = false;
const movesSelection = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1;
const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection);
if (moveMembership.groupPositions.length > 1) {
this.moveSubmapGroup(record, x, y, moveMembership);
queueMicrotask(() => this.redrawAllConnectors());
}
this.scheduleBoundaryRefresh();
return { x, y };
}
scheduleBoundaryRefresh() {
if (this.boundaryRefreshScheduled) return;
this.boundaryRefreshScheduled = true;
window.requestAnimationFrame(() => {
this.boundaryRefreshScheduled = false;
if (!this.editor.destroyed) this.editor.refreshBoundaryReferences();
});
}
beginItemMove(record, includeDescendants = false) {
if (record.moveMembership) return record.moveMembership;
let groupItems = [record];
if (includeDescendants) {
const selected = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1 ?
this.editor.selectedAll() : [record];
const expanded = [];
for (const item of selected) {
expanded.push(item);
if (item.kind === "submap" && item !== this.editor.activeMapRoot) {
expanded.push(...this.items.filter((candidate) => this.editor.isDescendantOf(candidate, item)));
}
}
groupItems = Array.from(new Set(expanded));
}
record.moveMembership = {
parent: record.parentSubmap,
parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null,
startX: Number(record.node.attr("x")),
startY: Number(record.node.attr("y")),
groupPositions: groupItems.map((item) => ({
item,
x: Number(item.node.attr("x")),
y: Number(item.node.attr("y"))
}))
};
return record.moveMembership;
}
moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
const deltaX = x - moveMembership.startX;
const deltaY = y - moveMembership.startY;
for (const position of moveMembership.groupPositions) {
position.item.node.attr({
x: position.x + deltaX,
y: position.y + deltaY
});
position.item.node.redraw();
}
for (const connector of this.connectors) {
connector.link.straighten();
connector.link.redraw();
}
for (const submap of this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth)) {
this.editor.updateSubmapFrame(submap);
}
}
redrawAllConnectors() {
for (const connector of this.connectors) {
connector.link.straighten();
connector.link.redraw();
}
}
handleItemMoveEnd(record) {
const moveMembership = record.moveMembership;
record.moveMembership = null;
if (!moveMembership) return;
this.redrawAllConnectors();
const movedItems = this.editor.selectedAll().filter((item) =>
moveMembership.groupPositions.some((position) => position.item === item));
const movedParents = new Set(movedItems.map((item) => item.parentSubmap));
if (movedItems.length > 1 && movedParents.size === 1) {
const previousParent = movedItems[0].parentSubmap;
const centers = movedItems.map((item) => this.editor.itemCenter(item));
const center = {
x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length,
y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length
};
let parent = null;
if (previousParent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
parent = previousParent;
} else {
parent = this.editor.submapAtPoint(center, null, movedItems);
}
if (!parent && this.editor.activeMapRoot && !movedItems.includes(this.editor.activeMapRoot)) {
parent = this.editor.activeMapRoot;
}
if (previousParent && parent !== previousParent && this.editor.onConfirmDetachFromSubmap &&
!this.editor.onConfirmDetachFromSubmap(record, previousParent, parent)) {
parent = previousParent;
}
if (parent !== previousParent) {
for (const item of movedItems) {
if (item === this.editor.activeMapRoot) continue;
item.parentSubmap = parent;
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
}
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
debug("selection submap membership changed", {
itemIds: movedItems.map((item) => item.id),
previousParentId: previousParent ? previousParent.id : null,
parentId: parent ? parent.id : null
});
}
this.editor.refreshSubmapVisibility();
this.editor.scheduleHistoryCommit();
return;
}
if (record.kind === "phrase") {
this.editor.scheduleHistoryCommit();
return;
}
if (record === this.editor.activeMapRoot) {
this.editor.refreshSubmapVisibility();
debug("active map head moved without changing parent membership", {
id: record.id,
parentId: record.parentSubmap ? record.parentSubmap.id : null
});
this.editor.scheduleHistoryCommit();
return;
}
const center = this.editor.itemCenter(record);
let parent = null;
if (moveMembership.parent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
parent = moveMembership.parent;
} else {
parent = this.editor.submapAtPoint(center, record);
}
if (!parent && this.editor.activeMapRoot && record !== this.editor.activeMapRoot) parent = this.editor.activeMapRoot;
if (moveMembership.parent && parent !== moveMembership.parent &&
this.editor.onConfirmDetachFromSubmap &&
!this.editor.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) {
parent = moveMembership.parent;
}
if (parent !== record.parentSubmap) {
const previousParent = record.parentSubmap;
record.parentSubmap = parent;
this.editor.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0);
debug("item submap membership changed", {
id: record.id,
previousParentId: previousParent ? previousParent.id : null,
parentId: parent ? parent.id : null
});
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
}
this.editor.refreshSubmapVisibility();
this.editor.scheduleHistoryCommit();
}
startSubmapFrameDrag(event, record) {
event.preventDefault();
event.stopPropagation();
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(record)) {
this.editor.selectedItem = record;
this.editor.refreshSelectionDecoration();
this.editor.notifySelection();
} else {
this.editor.selectItem(record, { additive });
}
const pointerId = event.pointerId;
const startClientX = event.clientX;
const startClientY = event.clientY;
const bounds = this.editor.submapBounds(record);
if (!bounds) return;
const moveMembership = {
parent: record.parentSubmap,
parentBounds: null,
startX: bounds.left,
startY: bounds.top,
groupPositions: this.items
.filter((item) => this.editor.isDescendantOf(item, record))
.map((item) => ({
item,
x: Number(item.node.attr("x")),
y: Number(item.node.attr("y"))
}))
};
record.moveMembership = moveMembership;
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
const x = moveMembership.startX +
((moveEvent.clientX - startClientX) / this.zoomFactor);
const y = moveMembership.startY +
((moveEvent.clientY - startClientY) / this.zoomFactor);
this.moveSubmapGroup(record, x, y, moveMembership);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
record.moveMembership = null;
this.editor.saveCurrentContextLayout();
this.editor.refreshSubmapVisibility();
this.editor.scheduleHistoryCommit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
installMarqueeSelection() {
this.marqueeMouseDownHandler = (event) => {
if (event.button !== 0) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest(
"[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
const start = this.editor.canvasPoint(event);
if (this.editor.pointNearConnector(start)) return;
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
const surface = this.editor.surfaceElement() || this.canvas;
const marquee = document.createElement("div");
marquee.className = "rw-cmap-marquee";
Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" });
surface.append(marquee);
const cleanup = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
marquee.remove();
if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null;
};
const move = (moveEvent) => {
const point = this.editor.canvasPoint(moveEvent);
const left = Math.min(start.x, point.x);
const top = Math.min(start.y, point.y);
Object.assign(marquee.style, {
left: `${left}px`,
top: `${top}px`,
width: `${Math.abs(point.x - start.x)}px`,
height: `${Math.abs(point.y - start.y)}px`
});
};
const up = (upEvent) => {
const point = this.editor.canvasPoint(upEvent);
cleanup();
const bounds = {
left: Math.min(start.x, point.x),
top: Math.min(start.y, point.y),
right: Math.max(start.x, point.x),
bottom: Math.max(start.y, point.y)
};
if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) {
if (!additive) this.editor.clearSelection();
return;
}
if (!additive) this.editor.clearSelection(false);
const matches = this.items.filter((item) => {
if (!this.editor.isEffectiveItemVisible(item)) return false;
const left = Number(item.node.attr("x"));
const top = Number(item.node.attr("y"));
const right = left + Number(item.node.attr("width"));
const bottom = top + Number(item.node.attr("height"));
return right >= bounds.left && left <= bounds.right &&
bottom >= bounds.top && top <= bounds.bottom;
});
const expanded = new Set(matches);
for (const item of matches) {
if (!item.groupId) continue;
for (const member of this.items.filter((candidate) =>
candidate.groupId === item.groupId && this.editor.isItemVisible(candidate))) expanded.add(member);
}
for (const item of expanded) this.editor.selectedItems.add(item);
this.editor.selectedItem = matches.at(-1) || this.editor.selectedItem;
this.editor.selectedConnector = null;
this.editor.refreshSelectionDecoration();
this.editor.notifySelection();
debug("marquee selection applied", {
selectedIds: this.editor.selectedAll().map((item) => item.id)
});
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
this.activeMarqueeCleanup = cleanup;
};
this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler);
}
installCanvasPanning() {
this.canvasPanPointerDownHandler = (event) => {
const target = event.target instanceof Element ? event.target : null;
if (target && target.closest(
"[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
if (event.button !== 1 && !(event.button === 0 && event.altKey)) return;
event.preventDefault();
if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
const startX = event.clientX;
const startY = event.clientY;
const startScrollLeft = this.canvas.scrollLeft;
const startScrollTop = this.canvas.scrollTop;
const pointerId = event.pointerId;
this.canvas.classList.add("rw-cmap-canvas-panning");
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
this.canvas.scrollLeft = startScrollLeft - (moveEvent.clientX - startX);
this.canvas.scrollTop = startScrollTop - (moveEvent.clientY - startY);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.canvas.classList.remove("rw-cmap-canvas-panning");
if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.canvas.classList.remove("rw-cmap-canvas-panning");
if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
this.activeCanvasPanCleanup = cleanup;
};
this.canvas.addEventListener("pointerdown", this.canvasPanPointerDownHandler);
}
handleMapSelection(component, event) {
if (event.target instanceof Element &&
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
debug("cmap selection belongs to an editor control", elementDescription(event.target));
return;
}
const item = this.items.find((candidate) => candidate.node === component) || null;
const connector = this.connectors.find((candidate) => candidate.link === component) || null;
debug("selection callback received from cmap hit test", {
componentFound: Boolean(component),
itemId: item ? item.id : null,
connectorId: connector ? connector.id : null,
target: elementDescription(event.target)
});
if (item) {
const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey));
if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(item)) {
this.editor.selectedItem = item;
this.editor.refreshSelectionDecoration();
this.editor.notifySelection();
return;
}
this.editor.selectItem(item, { additive, toggle: additive });
return;
}
if (connector) {
this.editor.selectConnector(connector);
return;
}
if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.editor.clearSelection();
}
handleMapActivation(component, event) {
const item = this.items.find((candidate) => candidate.node === component) || null;
debug("activation callback received from cmap", {
itemId: item ? item.id : null,
kind: item ? item.kind : null,
pageSlug: item ? item.pageSlug : null
});
if (!item) return;
if (event && event.preventDefault) event.preventDefault();
if (item.kind === "phrase") {
this.editor.editPhraseInline(item);
return;
}
if (this.editor.onEditItem) {
this.editor.selectItem(item);
this.editor.onEditItem(item);
}
}
startResize(event, record) {
event.preventDefault();
event.stopPropagation();
record.autoWidth = false;
record.autoHeight = false;
record.fitContentPending = false;
const startX = event.clientX;
const startY = event.clientY;
const startWidth = Number(record.node.attr("width"));
const startHeight = Number(record.node.attr("height"));
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor));
record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor));
record.node.attr({ width: record.width, height: record.height });
record.node.redraw();
this.editor.decorateItem(record);
this.editor.redrawConnectorsFor(record);
this.editor.ensureCanvasExtent(Number(record.node.attr("x")) + record.width,
Number(record.node.attr("y")) + record.height);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.editor.decorateItem(record);
this.editor.scheduleHistoryCommit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
startRelationDrag(event, source) {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const start = this.editor.itemCenter(source);
const draft = this.createDraftLine(start);
this.editor.dragRelation = { source, draft };
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const point = this.editor.canvasPoint(moveEvent);
this.editor.ensureCanvasExtent(point.x, point.y);
draft.line.setAttribute("x2", String(point.x));
draft.line.setAttribute("y2", String(point.y));
draft.svg.setAttribute("width", String(Math.max(this.editor.logicalCanvasWidth(), point.x + 180)));
draft.svg.setAttribute("height", String(Math.max(this.editor.logicalCanvasHeight(), point.y + 180)));
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
const target = this.editor.itemAt(upEvent.clientX, upEvent.clientY);
const point = this.editor.canvasPoint(upEvent);
draft.svg.remove();
this.editor.dragRelation = null;
if (!target) {
this.editor.ensureCanvasExtent(point.x, point.y);
const parentSubmap = this.editor.submapAtPoint(point);
debug("relation dropped on empty canvas", {
sourceId: source.id,
point,
parentSubmapId: parentSubmap ? parentSubmap.id : null
});
if (this.editor.onCreateConnectedItem) this.editor.onCreateConnectedItem({ source, point, parentSubmap });
return;
}
if (target === source) return;
this.finishRelation(source, target, event.altKey || upEvent.altKey);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
finishRelation(source, target, direct = false) {
if (source.kind === "phrase" && target.kind !== "phrase") {
this.editor.addConnector(source, target, true);
this.editor.reconcilePhraseMembership(source);
this.editor.refreshSubmapVisibility();
this.editor.selectItem(source);
return;
}
if (source.kind !== "phrase" && target.kind === "phrase") {
this.editor.addConnector(source, target, false);
this.editor.reconcilePhraseMembership(target);
this.editor.refreshSubmapVisibility();
this.editor.selectItem(target);
return;
}
if (source.kind === "phrase" && target.kind === "phrase") return;
if (direct) {
const connector = this.editor.addConnector(source, target, true);
this.editor.refreshSubmapVisibility();
this.editor.selectConnector(connector);
return;
}
this.editor.connectWithPhrase(source, target, "?????", true);
}
createDraftLine(start) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.classList.add("rw-cmap-draft-layer");
svg.setAttribute("width", String(this.editor.logicalCanvasWidth()));
svg.setAttribute("height", String(this.editor.logicalCanvasHeight()));
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", String(start.x));
line.setAttribute("y1", String(start.y));
line.setAttribute("x2", String(start.x));
line.setAttribute("y2", String(start.y));
line.setAttribute("class", "rw-cmap-draft-line");
svg.append(line);
(this.editor.surfaceElement() || this.canvas).append(svg);
return { svg, line };
}
destroy() {
if (this.marqueeMouseDownHandler) {
this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler);
this.marqueeMouseDownHandler = null;
}
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
if (this.canvasPanPointerDownHandler) {
this.canvas.removeEventListener("pointerdown", this.canvasPanPointerDownHandler);
this.canvasPanPointerDownHandler = null;
}
if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
}
}
@@ -0,0 +1,87 @@
"use strict";
/** Own map-context layouts and apply them to the rendered editor records. */
export class CmapLayoutController {
constructor(editor) {
this.editor = editor;
}
itemLayout(record) {
return {
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y")),
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height")),
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
textColor: record.textColor,
fontFamily: record.fontFamily,
fontSize: record.fontSize,
fontWeight: record.fontWeight,
fontStyle: record.fontStyle,
synopsisTextColor: record.synopsisTextColor,
synopsisFontFamily: record.synopsisFontFamily,
synopsisFontSize: record.synopsisFontSize,
synopsisFontWeight: record.synopsisFontWeight,
synopsisFontStyle: record.synopsisFontStyle
};
}
saveCurrentContextLayout() {
const context = this.editor.mapContextKey();
for (const record of this.editor.items.filter((item) => this.editor.isEffectiveItemVisible(item))) {
record.layouts[context] = this.itemLayout(record);
}
}
applyCurrentContextLayout() {
const context = this.editor.mapContextKey();
const visible = this.editor.items.filter((record) => this.editor.isEffectiveItemVisible(record));
let offset = { x: 0, y: 0 };
if (this.editor.activeMapRoot && !this.editor.activeMapRoot.layouts[context]) {
const rootLayout = this.itemLayout(this.editor.activeMapRoot);
offset = { x: 80 - rootLayout.x, y: 80 - rootLayout.y };
}
for (const record of visible) {
if (!record.layouts[context]) {
const current = this.itemLayout(record);
record.layouts[context] = { ...current, x: current.x + offset.x, y: current.y + offset.y };
}
const layout = record.layouts[context];
record.width = layout.width;
record.height = layout.height;
record.backgroundColor = layout.backgroundColor || record.backgroundColor;
record.borderColor = layout.borderColor || record.borderColor;
record.textColor = layout.textColor || record.textColor;
record.fontFamily = layout.fontFamily || record.fontFamily;
record.fontSize = layout.fontSize || record.fontSize;
record.fontWeight = layout.fontWeight || record.fontWeight;
record.fontStyle = layout.fontStyle || record.fontStyle;
record.synopsisTextColor = layout.synopsisTextColor || layout.textColor || record.synopsisTextColor;
record.synopsisFontFamily = layout.synopsisFontFamily || layout.fontFamily || record.synopsisFontFamily;
record.synopsisFontSize = layout.synopsisFontSize || record.synopsisFontSize;
record.synopsisFontWeight = layout.synopsisFontWeight || layout.fontWeight || record.synopsisFontWeight;
record.synopsisFontStyle = layout.synopsisFontStyle || layout.fontStyle || record.synopsisFontStyle;
record.node.attr({
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height,
content: this.editor.itemHtml(record),
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
textColor: record.textColor
});
record.node.redraw();
}
this.editor.refreshSubmapVisibility();
this.editor.refreshConnectorGeometry();
window.requestAnimationFrame(() => {
if (!this.editor.destroyed && this.editor.canvas.isConnected) {
this.editor.refreshSubmapVisibility();
this.editor.refreshConnectorGeometry();
}
});
}
}
@@ -0,0 +1,147 @@
"use strict";
import { ConceptMapConnector } from "../model/concept-map.js";
import { debug, numberOr } from "../cmap-utils.js";
/** Own map-local connector mutations and linking-phrase membership. */
export class CmapRelationController {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
const a = this.editor.itemCenter(source);
const b = this.editor.itemCenter(target);
const parentSubmap = this.commonSubmapParent([source, target]);
const phrase = this.editor.addItem({
kind: "phrase",
label,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: ((a.x + b.x) / 2) - 72,
y: ((a.y + b.y) / 2) - 18,
backgroundColor: "#fbfbf8",
borderColor: "transparent"
});
this.editor.addConnector(source, phrase, false);
this.editor.addConnector(phrase, target, true);
this.reconcilePhraseMembership(phrase);
this.editor.selectItem(phrase);
if (editImmediately) this.editor.editPhraseInline(phrase);
return phrase;
}
submapChain(record) {
const result = [];
if (record.kind === "submap") result.push(record);
let parent = record.parentSubmap;
while (parent) {
result.push(parent);
parent = parent.parentSubmap;
}
return result;
}
commonSubmapParent(records) {
if (!records.length) return null;
const chains = records.map((record) => this.submapChain(record));
return chains[0]
.filter((candidate) => chains.every((chain) => chain.includes(candidate)))
.sort((a, b) => b.submapDepth - a.submapDepth)[0] || null;
}
reconcilePhraseMembership(phrase = null) {
const phrases = phrase ? [phrase] : this.items.filter((item) => item.kind === "phrase");
for (const item of phrases) {
const endpoints = this.connectors
.filter((connector) => connector.source === item || connector.target === item)
.map((connector) => connector.source === item ? connector.target : connector.source)
.filter((endpoint) => endpoint.kind !== "phrase");
if (!endpoints.length) continue;
const parent = this.commonSubmapParent(endpoints);
item.parentSubmap = parent;
item.submapDepth = parent ? parent.submapDepth + 1 : 0;
}
}
addConnector(source, target, hasArrow = true, options = {}) {
const sourceCenter = this.editor.itemCenter(source);
const targetCenter = this.editor.itemCenter(target);
const link = this.editor.view.createConnector({
content: "",
width: 1,
height: 1,
backgroundColor: "transparent",
borderColor: "transparent",
borderWidth: 0,
lineColor: options.lineColor || "#333",
lineWidth: numberOr(Number(options.lineWidth), 2),
hasArrow,
cx: (sourceCenter.x + targetCenter.x) / 2,
cy: (sourceCenter.y + targetCenter.y) / 2,
sourceX: sourceCenter.x,
sourceY: sourceCenter.y,
targetX: targetCenter.x,
targetY: targetCenter.y
});
link.sourceNode(source.node).targetNode(target.node);
link.straighten();
link.draggable(true);
const record = {
id: Number.isInteger(Number(options.id)) && Number(options.id) > 0 ?
Number(options.id) : this.editor.nextConnectorId,
link,
source,
target,
visualSource: source,
visualTarget: target,
hasArrow,
lineColor: options.lineColor || "#333",
lineWidth: numberOr(Number(options.lineWidth), 2)
};
this.editor.nextConnectorId = Math.max(this.editor.nextConnectorId, record.id + 1);
this.editor.documents.attachConnectorToModel(record);
this.connectors.push(record);
link.onRendered((_renderedLink, element) => this.editor.decorateConnector(record, element));
link.onConnectionChange((_changedLink, type, node) =>
this.editor.handleConnectorConnectionChange(record, type, node));
link.visible(this.editor.isItemVisible(source) && this.editor.isItemVisible(target));
this.editor.scheduleHistoryCommit();
return record;
}
handleConnectorConnectionChange(connector, type, node) {
if (!connector || (type !== "source" && type !== "target")) return false;
const endpoint = this.items.find((item) => item.node === node) || null;
const otherType = type === "source" ? "target" : "source";
if (!endpoint || endpoint === connector[otherType]) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
this.editor.applyConnectorVisualEndpoints(connector,
this.editor.connectorEndpoint(connector.source),
this.editor.connectorEndpoint(connector.target));
return false;
}
if (connector[type] === endpoint) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
return false;
}
const previous = connector[type];
connector[type] = endpoint;
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
this.reconcilePhraseMembership();
this.editor.refreshSubmapVisibility();
this.editor.refreshConnectorGeometry();
debug("connector endpoint changed", {
connectorId: connector.id,
type,
previousItemId: previous ? previous.id : null,
itemId: endpoint.id
});
this.editor.scheduleHistoryCommit();
return true;
}
}
@@ -0,0 +1,532 @@
"use strict";
import { debug, normalizeConceptTags } from "../cmap-utils.js";
let copiedConceptReferences = [];
export class CmapSelectionController {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get selectedItem() { return this.editor.selectedItem; }
set selectedItem(val) { this.editor.selectedItem = val; }
get selectedItems() { return this.editor.selectedItems; }
get selectedConnector() { return this.editor.selectedConnector; }
set selectedConnector(val) { this.editor.selectedConnector = val; }
selectItem(record, options = {}) {
if (!record) {
this.clearSelection();
return;
}
const additive = Boolean(options.additive);
const toggle = Boolean(options.toggle);
const groupRecords = record.groupId && options.expandGroup !== false ?
this.items.filter((item) => item.groupId === record.groupId && this.editor.isItemVisible(item)) :
[record];
debug("selectItem called", {
requestedId: record.id,
requestedKind: record.kind,
additive,
groupId: record.groupId,
previousIds: this.selectedAll().map((item) => item.id)
});
if (!additive) this.clearSelection(false);
const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item));
for (const item of groupRecords) {
if (remove) {
this.selectedItems.delete(item);
} else {
this.selectedItems.add(item);
}
}
this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record;
this.selectedConnector = null;
this.refreshSelectionDecoration();
debug("selection applied", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
selectedIds: this.selectedAll().map((item) => item.id),
selectionCount: this.selectedItems.size
});
this.notifySelection();
}
refreshSelectionDecoration() {
for (const item of this.items) {
const element = item.node.element();
const selected = this.selectedItems.has(item);
if (element) {
element.classList.toggle("rw-cmap-selected", selected);
element.classList.toggle(
"rw-cmap-selected-primary", selected && item === this.selectedItem);
if (selected) {
element.setAttribute("aria-selected", "true");
item.node.toFront();
} else {
element.removeAttribute("aria-selected");
}
this.editor.removeHandles(element);
if (selected && item === this.selectedItem) this.editor.ensureHandles(item, element);
}
if (item.kind === "submap") this.editor.updateSubmapFrame(item);
}
this.editor.submaps.refreshGroupSelection();
}
selectConnector(record) {
this.clearSelection();
this.selectedConnector = record;
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
record.link.redraw();
this.notifySelection();
}
clearSelection(notify = true) {
const clearedItemIds = this.selectedAll().map((item) => item.id);
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
for (const item of this.selectedItems) {
const element = item.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.classList.remove("rw-cmap-selected-primary");
element.removeAttribute("aria-selected");
this.editor.removeHandles(element);
}
}
if (this.selectedConnector) {
const connector = this.selectedConnector;
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
connector.link.redraw();
}
this.selectedItem = null;
this.selectedItems.clear();
this.selectedConnector = null;
if (clearedItemIds.length || clearedConnectorId) {
debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId });
}
if (notify) this.notifySelection();
}
selected() {
return this.selectedItem;
}
selectedAll() {
return Array.from(this.selectedItems);
}
storeConceptReferences(records) {
if (!records.length) return 0;
const referencesById = new Map(records.map((source) =>
[source.conceptId, {
conceptId: source.conceptId,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
externalUrl: source.externalUrl,
imageSource: source.imageSource,
width: Number(source.node.attr("width")),
height: Number(source.node.attr("height")),
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
fontFamily: source.fontFamily,
fontSize: source.fontSize,
fontWeight: source.fontWeight,
fontStyle: source.fontStyle,
synopsisTextColor: source.synopsisTextColor,
synopsisFontFamily: source.synopsisFontFamily,
synopsisFontSize: source.synopsisFontSize,
synopsisFontWeight: source.synopsisFontWeight,
synopsisFontStyle: source.synopsisFontStyle
}]));
copiedConceptReferences = Array.from(referencesById.values());
return copiedConceptReferences.length;
}
copySelectionReferences() {
const selected = this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase");
const copied = this.storeConceptReferences(selected);
if (!copied) return 0;
this.notifySelection();
return copied;
}
canCutSelectionReferences() {
return this.selectedAll().some((item) =>
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
}
cutSelectionReferences() {
const cuttable = this.selectedAll().filter((item) =>
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
if (!cuttable.length) return 0;
const copied = this.storeConceptReferences(cuttable);
if (!copied) return 0;
this.clearSelection(false);
for (const record of cuttable) this.selectedItems.add(record);
this.selectedItem = cuttable.at(-1) || null;
this.deleteSelection();
return copied;
}
canPasteConceptReferences() {
return copiedConceptReferences.length > 0;
}
pasteConceptReferences() {
const sources = copiedConceptReferences;
if (!sources.length) return [];
this.clearSelection(false);
const parentSubmap = this.editor.activeMapRoot || null;
const pasted = sources.map((source, index) => this.editor.addItem({
conceptId: source.conceptId,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: source.aspects,
tags: source.tags,
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
externalUrl: source.externalUrl,
parentCmapLink: false,
imageSource: source.imageSource,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: 120 + (index * 36),
y: 120 + (index * 36),
width: source.width,
height: source.height,
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
fontFamily: source.fontFamily,
fontSize: source.fontSize,
fontWeight: source.fontWeight,
fontStyle: source.fontStyle,
synopsisTextColor: source.synopsisTextColor,
synopsisFontFamily: source.synopsisFontFamily,
synopsisFontSize: source.synopsisFontSize,
synopsisFontWeight: source.synopsisFontWeight,
synopsisFontStyle: source.synopsisFontStyle
}));
for (const record of pasted) this.selectedItems.add(record);
this.selectedItem = pasted.at(-1) || null;
this.refreshSelectionDecoration();
this.notifySelection();
return pasted;
}
selectAll() {
this.clearSelection(false);
for (const item of this.items) {
if (this.editor.isEffectiveItemVisible(item)) this.selectedItems.add(item);
}
this.selectedItem = this.selectedAll().at(-1) || null;
this.refreshSelectionDecoration();
this.notifySelection();
return this.selectedAll();
}
layoutSelectionRecords() {
return this.selectedAll().filter((item) => this.editor.isEffectiveItemVisible(item));
}
canLayoutSelection(command) {
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
return this.layoutSelectionRecords().length >= minimum;
}
applySelectionLayout(command) {
const records = this.layoutSelectionRecords();
if (!this.canLayoutSelection(command)) return false;
const boxes = records.map((record) => ({
record,
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y")),
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height"))
}));
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
const updates = new Map(boxes.map(({ record }) => [record, {}]));
const referenceRight = reference.x + reference.width;
const referenceCenter = reference.x + (reference.width / 2);
const referenceBottom = reference.y + reference.height;
const referenceMiddle = reference.y + (reference.height / 2);
if (["same-width", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).width = reference.width;
}
if (["same-height", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).height = reference.height;
}
if (command === "align-left") {
for (const box of boxes) updates.get(box.record).x = reference.x;
}
if (command === "align-right") {
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
}
if (command === "align-center") {
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
}
if (command === "align-top") {
for (const box of boxes) updates.get(box.record).y = reference.y;
}
if (command === "align-bottom") {
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
}
if (command === "align-middle") {
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
}
if (command === "distribute-horizontal") {
const ordered = [...boxes].sort((a, b) =>
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionLeft = ordered[0].x;
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
let cursor = distributionLeft;
for (const box of ordered) {
updates.get(box.record).x = cursor;
cursor += box.width + gap;
}
}
if (command === "distribute-vertical") {
const ordered = [...boxes].sort((a, b) =>
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionTop = ordered[0].y;
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
let cursor = distributionTop;
for (const box of ordered) {
updates.get(box.record).y = cursor;
cursor += box.height + gap;
}
}
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
"align-center", "align-top", "align-bottom", "align-middle",
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
this.editor.scheduleHistoryCommit();
for (const record of records) {
const attributes = updates.get(record);
if (attributes.width !== undefined) {
record.width = attributes.width;
record.autoWidth = false;
}
if (attributes.height !== undefined) {
record.height = attributes.height;
record.autoHeight = false;
}
record.node.attr(attributes);
record.node.redraw();
}
this.editor.saveCurrentContextLayout();
this.editor.refreshConnectorGeometry();
for (const submap of this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth)) {
this.editor.updateSubmapFrame(submap);
}
this.refreshSelectionDecoration();
debug("selection layout applied", {
command,
referenceItemId: reference.record.id,
itemIds: records.map((record) => record.id)
});
return true;
}
canGroupSelection() {
const selected = this.selectedAll();
return selected.length >= 2 &&
selected.every((item) => item.parentSubmap === selected[0].parentSubmap);
}
groupSelection(options = {}) {
const selected = this.selectedAll();
if (!this.canGroupSelection()) return false;
const parentSubmap = selected[0].parentSubmap;
const left = Math.min(...selected.map((item) => Number(item.node.attr("x"))));
const top = Math.min(...selected.map((item) => Number(item.node.attr("y"))));
const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap";
const submap = this.editor.addItem({
...options,
kind: "submap",
label,
childMap: options.childMap || label,
synopsis: options.synopsis || "Grouped sub-concept map.",
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: (options.x === undefined || options.x === null) ? left : Number(options.x),
y: (options.y === undefined || options.y === null) ? Math.max(20, top - 105) : Number(options.y),
backgroundColor: options.backgroundColor || "#edf7e8",
borderColor: options.borderColor || "#57834a"
});
submap.expanded = true;
submap.submapInitialized = true;
for (const item of selected) {
item.groupId = null;
item.parentSubmap = submap;
this.editor.updateSubmapDepth(item, submap.submapDepth + 1);
}
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.applyCurrentContextLayout();
this.selectItem(submap);
debug("selection grouped as submap", {
submapId: submap.id,
itemIds: selected.map((item) => item.id)
});
return submap;
}
canUngroupSelection() {
return this.selectedAll().some((item) =>
Boolean(item.groupId) ||
(item.kind === "submap" && !item.separateMap) ||
Boolean(item.parentSubmap && item.parentSubmap !== this.editor.activeMapRoot));
}
ungroupSelection() {
const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean));
const affected = this.items.filter((item) => groupIds.has(item.groupId));
for (const item of affected) item.groupId = null;
const selected = this.selectedAll();
const selectedSubmaps = new Set(selected.filter((item) =>
item.kind === "submap" && !item.separateMap));
const liftedChildren = new Set();
for (const submap of selectedSubmaps) {
const parent = submap.parentSubmap;
const children = this.items.filter((item) => item.parentSubmap === submap);
for (const child of children) {
liftedChildren.add(child);
child.parentSubmap = parent;
this.editor.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
}
submap.expanded = false;
submap.submapInitialized = false;
submap.childMap = null;
this.editor.updateItem(submap, { kind: "concept" });
affected.push(submap, ...children);
}
for (const item of selected) {
if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap ||
item.parentSubmap === this.editor.activeMapRoot) continue;
const parent = item.parentSubmap.parentSubmap;
item.parentSubmap = parent;
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
affected.push(item);
}
if (!affected.length) return false;
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.refreshSubmapVisibility();
this.refreshSelectionDecoration();
debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
this.notifySelection();
this.editor.scheduleHistoryCommit();
return true;
}
deleteSelection() {
const records = new Set(this.selectedAll().filter((item) => item !== this.editor.activeMapRoot));
for (const record of Array.from(records)) {
if (record.kind === "submap") {
for (const item of this.items) {
if (this.editor.isDescendantOf(item, record)) records.add(item);
}
}
}
const connectors = new Set(this.connectors.filter((connector) =>
connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target)));
const affectedPhrases = new Set();
for (const connector of connectors) {
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
affectedPhrases.add(connector.source);
}
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
affectedPhrases.add(connector.target);
}
}
let foundOrphan = true;
while (foundOrphan) {
foundOrphan = false;
const remainingConnectors = this.connectors.filter((connector) =>
!connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target));
for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) {
const hasSource = remainingConnectors.some((connector) => connector.target === phrase);
const hasTarget = remainingConnectors.some((connector) => connector.source === phrase);
if (hasSource && hasTarget) continue;
records.add(phrase);
for (const connector of this.connectors) {
if (connector.source !== phrase && connector.target !== phrase) continue;
connectors.add(connector);
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
affectedPhrases.add(connector.source);
}
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
affectedPhrases.add(connector.target);
}
}
foundOrphan = true;
}
}
if (!records.size && !connectors.size) return false;
this.clearSelection(false);
for (const connector of connectors) {
connector.link.remove();
this.editor.model.conceptMap.removeConnector(connector.id);
}
this.editor.connectors = this.connectors.filter((connector) => !connectors.has(connector));
for (const record of records) {
if (record.mapReference && record.mapReference.id) this.editor.conceptMaps.delete(record.mapReference.id);
record.node.remove();
this.editor.model.conceptMap.removeItem(record.id);
}
if (records.size && this.editor.unresolvedConnectors.length) {
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
this.editor.unresolvedConnectors = this.editor.unresolvedConnectors.filter((connector) =>
!deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
}
this.editor.items = this.items.filter((item) => !records.has(item));
this.editor.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.refreshSubmapVisibility();
this.notifySelection();
debug("selection deleted", {
itemIds: Array.from(records).map((item) => item.id),
connectorIds: Array.from(connectors).map((connector) => connector.id)
});
this.editor.scheduleHistoryCommit();
return true;
}
notifySelection() {
if (this.editor.onSelectionChange) {
this.editor.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
}
}
}
@@ -0,0 +1,340 @@
/**
* Coordinates submap membership, navigation and visibility for the wiki editor.
*
* The editor remains responsible for item storage and drawing. This controller
* owns the rules for the active map context and delegates rendering and model
* synchronization through the supplied editor instance.
*/
export class CmapSubmapController {
/**
* goal : Create the controller for one wiki CMap editor.
* pre : editor owns the items, view, model and editor callbacks.
* post : Submap operations can delegate rendering and model work to editor.
* result : A CmapSubmapController instance.
* internals : The controller keeps no duplicate item state; its accessors
* read the editor's active context and map history when an operation runs.
*
* @param {object} editor The editor facade that owns items and rendering.
*/
constructor(editor) {
this.editor = editor;
this.diagramGroups = new Map();
}
get items() { return this.editor.items; }
get activeMapRoot() { return this.editor.activeMapRoot; }
set activeMapRoot(value) { this.editor.activeMapRoot = value; }
get mapHistory() { return this.editor.mapHistory; }
get onMapChange() { return this.editor.onMapChange; }
/** Return whether record is nested below submap. */
isDescendantOf(record, submap) {
let parent = record.parentSubmap;
while (parent) {
if (parent === submap) return true;
parent = parent.parentSubmap;
}
return false;
}
/** Return whether record belongs to the currently opened map context. */
itemInsideActiveMap(record) {
return Boolean(this.activeMapRoot &&
(record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot)));
}
/** Return the persistence key for the active map context. */
mapContextKey(root = this.activeMapRoot) {
return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root";
}
/**
* Determine visibility from hidden contexts, active root and expanded parents.
* The result controls both item rendering and connector endpoint projection.
*/
isItemVisible(record) {
const context = this.mapContextKey();
if (record !== this.activeMapRoot && record.hiddenContexts.has(context)) return false;
if (this.activeMapRoot) {
if (record === this.activeMapRoot) return true;
if (!this.isDescendantOf(record, this.activeMapRoot)) return false;
let parent = record.parentSubmap;
while (parent && parent !== this.activeMapRoot) {
if (!parent.expanded) return false;
parent = parent.parentSubmap;
}
return parent === this.activeMapRoot;
}
let parent = record.parentSubmap;
while (parent) {
if (!parent.expanded) return false;
parent = parent.parentSubmap;
}
return true;
}
/** Return hidden non-phrase items ordered for the visibility picker. */
hiddenItemsInCurrentContext() {
const context = this.mapContextKey();
return this.items
.filter((item) => item !== this.activeMapRoot && item.kind !== "phrase" &&
item.hiddenContexts.has(context))
.sort((left, right) => left.label.localeCompare(right.label));
}
/** Return whether the current selection contains an item that can be hidden. */
canHideSelectionInCurrentContext() {
return !this.activeMapRoot && this.editor.selectedAll().some((item) => item.parentSubmap &&
item.kind !== "phrase" && this.isItemVisible(item));
}
/** Hide selected child concepts in the current root context. */
hideSelectionInCurrentContext() {
const context = this.mapContextKey();
if (this.activeMapRoot) return false;
const selected = this.editor.selectedAll().filter((item) => item.parentSubmap &&
item.kind !== "phrase" && this.isItemVisible(item));
if (!selected.length) return false;
this.editor.scheduleHistoryCommit();
for (const item of selected) item.hiddenContexts.add(context);
this.editor.clearSelection();
this.refreshVisibility();
if (this.editor.onVisibilityChange) {
this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext());
}
return true;
}
/** Show one item again in the current root context. */
showItemInCurrentContext(record) {
if (!record) return false;
const context = this.mapContextKey();
if (!record.hiddenContexts.has(context)) return false;
this.editor.scheduleHistoryCommit();
record.hiddenContexts.delete(context);
this.refreshVisibility();
if (this.editor.onVisibilityChange) {
this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext());
}
return true;
}
/** Populate a lazy submap once, then reuse its editor records. */
ensureSubmapContents(record) {
if (record.submapInitialized) return;
record.submapInitialized = true;
if (this.editor.onPopulateSubMap) this.editor.onPopulateSubMap(record, this.editor);
}
/** Expand/collapse a submap or open its separate map representation. */
toggleSubmap(record, expanded = !record.expanded) {
if (!record || record.kind !== "submap") return false;
if (record.separateMap && !record.cmapSlug) {
record.expanded = false;
if (record === this.activeMapRoot) {
this.refreshVisibility();
return false;
}
return this.openSubmapMap(record);
}
this.editor.saveCurrentContextLayout();
if (expanded) this.ensureSubmapContents(record);
record.expanded = Boolean(expanded);
if (record.expanded) this.editor.applyCurrentContextLayout();
else this.refreshVisibility();
const element = record.node.element();
if (element) this.editor.ensureSubmapToggle(record, element);
if (this.editor.onOpenSubMap) this.editor.onOpenSubMap(record, record.expanded);
this.editor.scheduleHistoryCommit();
return record.expanded;
}
/** Open a separate submap and preserve the previous map on the navigation stack. */
openSubmapMap(record) {
if (!record || record.kind !== "submap" || !record.separateMap) return false;
if (record === this.activeMapRoot) return true;
this.ensureSubmapContents(record);
this.editor.clearSelection();
this.editor.saveCurrentContextLayout();
if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot);
this.activeMapRoot = record;
this.editor.applyCurrentContextLayout();
if (this.onMapChange) this.onMapChange(record.mapReference, record);
return true;
}
/** Return from a child context to the root map. */
openRootMap() {
if (!this.activeMapRoot) return false;
this.editor.clearSelection();
this.editor.saveCurrentContextLayout();
this.activeMapRoot = null;
this.editor.mapHistory = [];
this.editor.applyCurrentContextLayout();
if (this.onMapChange) this.onMapChange(null, null);
return true;
}
/** Return whether a parent context is available. */
canStepBackWithinMap() {
return this.mapHistory.length > 0;
}
/** Open exactly one parent context from the map navigation stack. */
openParentMap() {
if (!this.activeMapRoot) return false;
this.editor.clearSelection();
this.editor.saveCurrentContextLayout();
this.activeMapRoot = this.editor.mapHistory.pop() || null;
this.editor.applyCurrentContextLayout();
const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
return true;
}
/** Promote an embedded submap into a separately addressable CMap reference. */
promoteSubmap(record, name) {
if (!record || record.kind !== "submap") return null;
this.ensureSubmapContents(record);
record.childMap = String(name || record.label).trim() || record.label;
record.separateMap = true;
record.mapReference = {
id: `cmap-${record.id}`,
title: record.childMap,
rootItemId: record.id,
itemIds: this.items
.filter((item) => this.isDescendantOf(item, record))
.map((item) => item.id)
};
this.editor.conceptMaps.set(record.mapReference.id, record.mapReference);
record.expanded = false;
this.editor.updateItem(record, { synopsis: `Concept map: ${record.childMap}` });
if (this.editor.onSubMapPromoted) this.editor.onSubMapPromoted(record);
this.refreshVisibility();
return record.mapReference;
}
/** Prepare a submap model for storage as a separate CMap. */
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
if (!record || record.kind !== "submap") return null;
this.ensureSubmapContents(record);
return this.editor.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
}
/** Apply one submap record's current frame appearance immediately. */
updateGroupAppearance(record) {
const group = this.diagramGroups.get(record);
if (!group) return false;
group.setAppearance({
label: record.label,
backgroundColor: record.submapBackgroundColor || "#edf7e8",
borderColor: record.submapBorderColor || "#57834a"
});
return true;
}
/**
* Synchronize wiki submap membership with generic engine groups.
* Groups own the frame DOM; the editor remains responsible for wiki actions.
*/
refreshGroups() {
if (!this.editor.map || typeof this.editor.map.group !== "function") return;
const submaps = this.items
.filter((item) => item.kind === "submap")
.sort((left, right) => right.submapDepth - left.submapDepth);
const current = new Set(submaps);
for (const [record, group] of this.diagramGroups) {
if (!current.has(record)) {
group.destroy();
this.diagramGroups.delete(record);
}
}
for (const record of submaps) {
if (this.diagramGroups.has(record)) continue;
const group = this.editor.map.group({
label: record.label,
className: "rw-cmap-submap-frame",
backgroundColor: record.submapBackgroundColor || "#edf7e8",
borderColor: record.submapBorderColor || "#57834a",
padding: 34,
depth: record.submapDepth,
expanded: record.expanded,
manageVisibility: false,
onPointerDown: (event) => {
if (event.target?.closest?.(".rw-cmap-submap-frame-toggle")) return;
this.editor.startSubmapFrameDrag(event, record);
},
onDoubleClick: (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
if (this.editor.onEditItem) this.editor.onEditItem(record);
}
});
group.onToggle((_group, expanded) => this.toggleSubmap(record, expanded));
this.diagramGroups.set(record, group);
}
for (const record of submaps) {
const group = this.diagramGroups.get(record);
for (const member of [...group.members]) group.remove(member);
group.expanded = Boolean(record.expanded && record !== this.activeMapRoot &&
this.isItemVisible(record));
for (const child of this.items.filter((item) => item.parentSubmap === record)) {
if (child.kind === "submap") {
const childGroup = this.diagramGroups.get(child);
if (childGroup) group.add(childGroup);
}
// A nested submap's anchor is part of this group's layout. Its child
// group is added separately so that the nested contents get their own frame.
if (child.node) group.add(child.node);
}
group.setAppearance({
label: record.label,
backgroundColor: record.submapBackgroundColor || "#edf7e8",
borderColor: record.submapBorderColor || "#57834a"
});
group.depth = record.submapDepth;
group.redraw();
const element = group.element();
if (element) {
element.classList.toggle("rw-cmap-submap-frame-selected",
this.editor.selectedItems.has(record));
element.classList.toggle("rw-cmap-submap-frame-selected-primary",
this.editor.selectedItems.has(record) && this.editor.selectedItem === record);
}
this.editor.updateSubmapAnchorLine(record, group.bounds());
}
}
/** Update selection styling on already rendered group frames. */
refreshGroupSelection() {
for (const [record, group] of this.diagramGroups) {
const element = group.element();
if (!element) continue;
element.classList.toggle("rw-cmap-submap-frame-selected",
this.editor.selectedItems.has(record));
element.classList.toggle("rw-cmap-submap-frame-selected-primary",
this.editor.selectedItems.has(record) && this.editor.selectedItem === record);
}
}
/**
* Reconcile item visibility, projected connector endpoints and submap frames.
* The editor still owns the drawing operations; this method coordinates their
* order after a context or membership change.
*/
refreshVisibility() {
for (const item of this.items) item.node.visible(this.editor.isEffectiveItemVisible(item));
for (const connector of this.editor.connectors) {
this.editor.applyConnectorVisualEndpoints(connector,
this.editor.connectorEndpoint(connector.source),
this.editor.connectorEndpoint(connector.target));
}
this.refreshGroups();
for (const submap of this.items.filter((item) => item.kind === "submap")) {
const element = submap.node.element();
if (element) this.editor.ensureSubmapToggle(submap, element);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Public handle for one component rendered by a DiagramEngine.
*
* A handle exposes only the operations used by the CMap editor. The drawing
* object remains private to the engine so application code cannot depend on
* the representation inherited from the original renderer.
*/
export class DiagramComponent {
constructor(engine, component, attributeNames) {
this.engine = engine;
this.component = component;
this.attributeNames = attributeNames;
this.baseVisible = true;
}
/** Read or update the supported rendering attributes. */
attr(name, value) {
if (name === undefined) {
const attributes = {};
for (const attributeName of this.attributeNames) {
attributes[attributeName] = this.component[attributeName]();
}
return attributes;
}
if (isPlainObject(name)) {
for (const [attributeName, attributeValue] of Object.entries(name)) {
this.attr(attributeName, attributeValue);
}
return this;
}
if (!this.attributeNames.includes(name)) return this;
if (value === undefined) return this.component[name]();
this.component[name](value);
return this;
}
/** Remove this component from its diagram. */
remove() {
this.engine.removeComponent(this);
}
/** Move this component to the front of its own rendering band. */
toFront() {
this.engine.drawingSurface.toFront(this.component);
return this;
}
/** Return the concrete element currently rendering this component. */
element() {
return this.component.element();
}
/** Immediately render the current component state. */
redraw() {
this.component.redraw();
return this;
}
/** Read or change whether the component participates in the presentation. */
visible(value) {
if (value === undefined) return this.component.visible !== false;
this.baseVisible = Boolean(value);
this.engine.applyFilter(this);
return this.component.visible;
}
/** Register a callback invoked after this component has been rendered. */
onRendered(handler) {
validateOptionalHandler(handler, "render");
this.component.renderedHandler = handler ?
(element) => handler(this, element) : null;
if (handler && this.component.element()) handler(this, this.component.element());
return this;
}
/** Read or change whether the component can be dragged. */
draggable(enabled) {
if (enabled === undefined) {
return this.engine.drawingSurface.dragEnabled(this.component);
}
if (enabled) this.engine.drawingSurface.enableDrag(this.component);
else this.engine.drawingSurface.disableDrag(this.component);
return this;
}
}
/** Return a new object containing only supported input attributes. */
export function pickAttributes(attributes, names) {
if (attributes === undefined) return {};
if (!isPlainObject(attributes)) throw new TypeError("Invalid component attributes");
const selected = {};
for (const name of names) {
if (name in attributes) selected[name] = attributes[name];
}
return selected;
}
/** Validate an optional event handler at the public engine boundary. */
export function validateOptionalHandler(handler, meaning) {
if (handler !== null && handler !== undefined && typeof handler !== "function") {
throw new TypeError(`Invalid ${meaning} handler`);
}
}
/** Determine whether a value is a plain attributes object. */
function isPlainObject(value) {
return typeof value === "object" && value !== null &&
Object.prototype.toString.call(value) === "[object Object]";
}
+184
View File
@@ -0,0 +1,184 @@
import { DrawingSurface } from "./drawing-core.js";
import { validateOptionalHandler } from "./diagram-component.js";
import { DiagramLink } from "./diagram-link.js";
import { DiagramNode } from "./diagram-node.js";
import { DiagramGroup } from "./diagram-group.js";
/**
* Render and interact with a diagram of nodes and links.
*
* DiagramEngine is the complete public boundary of the drawing engine. It
* translates low-level hit-test results to DiagramNode and DiagramLink
* handles and owns every rendering object's lifetime.
*/
export class DiagramEngine {
constructor(element) {
this.drawingSurface = new DrawingSurface(element);
this.handles = new Map();
this.selectionHandler = null;
this.activationHandler = null;
this.filter = null;
this.groups = new Set();
this.destroyed = false;
this.drawingSurface.selectionHandler = (component, event) => {
if (this.selectionHandler) {
this.selectionHandler(component ? this.handleFor(component) : null, event);
}
};
this.drawingSurface.activationHandler = (component, event) => {
if (this.activationHandler) {
this.activationHandler(component ? this.handleFor(component) : null, event);
}
};
}
/** Register a callback for a single hit-tested component selection. */
onSelection(handler) {
validateOptionalHandler(handler, "selection");
this.selectionHandler = handler || null;
return this;
}
/** Register a callback for activation of one hit-tested component. */
onActivation(handler) {
validateOptionalHandler(handler, "activation");
this.activationHandler = handler || null;
return this;
}
/** Create and render a node owned by this engine. */
node(attributes) {
this.ensureActive();
const node = new DiagramNode(this, attributes);
this.addComponent(node);
return node;
}
/** Create and render a link owned by this engine. */
link(attributes) {
this.ensureActive();
const link = new DiagramLink(this, attributes);
this.addComponent(link);
return link;
}
/**
* Create a generic group frame for nodes and nested groups.
* @returns {DiagramGroup} A group owned by this engine.
*/
group(options) {
this.ensureActive();
const group = new DiagramGroup(this, options);
this.groups.add(group);
return group;
}
/**
* goal : Install a policy that controls component visibility.
* pre : filter is null or a function receiving a public component handle.
* post : All existing components use the new policy immediately.
* result : This engine, for fluent setup.
* internals : A policy may return a boolean or `{ visible }`; the component's
* own visibility remains the base value and is combined with the policy.
*/
setFilter(filter) {
if (filter !== null && filter !== undefined && typeof filter !== "function") {
throw new TypeError("A diagram filter must be a function");
}
this.filter = filter || null;
for (const handle of this.handles.values()) this.applyFilter(handle);
for (const group of this.groups) group.redraw();
return this;
}
/** Read or update the diagram zoom factor. */
zoom(value) {
if (value === undefined) return this.drawingSurface.zoomFactor;
const factor = Number(value);
if (!Number.isFinite(factor) || factor <= 0) {
throw new TypeError("Invalid zoom factor");
}
this.drawingSurface.zoomFactor = factor;
const element = this.drawingSurface.element();
if (element) element.style.zoom = String(factor);
return factor;
}
/** Destroy the surface and all nodes and links created through this engine. */
destroy() {
if (this.destroyed) return;
const element = this.drawingSurface.element();
this.selectionHandler = null;
this.activationHandler = null;
this.drawingSurface.selectionHandler = null;
this.drawingSurface.activationHandler = null;
for (const group of this.groups) group.destroy();
this.groups.clear();
for (const component of this.drawingSurface.componentList().toArray()) {
component.dispose();
component.parentElement(null);
}
this.drawingSurface.dispose();
this.handles.clear();
this.destroyed = true;
if (element && element.parentNode) element.parentNode.removeChild(element);
}
/** Return the public handle associated with a low-level drawing object. */
handleFor(component) {
return this.handles.get(component) || null;
}
/** Add a newly constructed public component to the drawing surface. */
addComponent(handle) {
this.handles.set(handle.component, handle);
this.drawingSurface.add(handle.component);
this.applyFilter(handle);
}
/** Remove a public component and forget its low-level drawing object. */
removeComponent(handle) {
if (!handle || handle.engine !== this || !this.handles.has(handle.component)) return;
this.drawingSurface.remove(handle.component);
this.handles.delete(handle.component);
}
/** Return the DOM surface on which components and group frames are drawn. */
surfaceElement() {
return this.drawingSurface.element();
}
/** Apply the current base visibility and optional external filter to a handle. */
applyFilter(handle) {
const decision = this.filter ? this.filter(handle) : true;
let visible = typeof decision === "boolean" ? decision : decision?.visible !== false;
if (handle instanceof DiagramLink) {
const source = handle.sourceNode();
const target = handle.targetNode();
visible = visible && (!source || source.visible()) && (!target || target.visible());
}
handle.component.visible = handle.baseVisible && visible;
handle.component.redraw();
if (handle instanceof DiagramNode) {
for (const candidate of this.handles.values()) {
if (!(candidate instanceof DiagramLink)) continue;
if (candidate.sourceNode() === handle || candidate.targetNode() === handle) {
this.applyFilter(candidate);
}
}
}
for (const group of this.groups) {
if (group.members.has(handle)) group.redraw();
}
}
/** Reject operations after the engine and its DOM surface were destroyed. */
ensureActive() {
if (this.destroyed) throw new Error("The diagram engine has been destroyed");
}
}
+204
View File
@@ -0,0 +1,204 @@
import { DiagramComponent } from "./diagram-component.js";
/**
* Render a nested group frame around diagram components.
*
* A group is a view-level container, not a domain model. It owns membership,
* expanded state and frame geometry while DiagramEngine continues to own the
* lifetime of nodes and links. Application code can map a wiki submap or any
* other grouping concept onto this generic abstraction.
*/
export class DiagramGroup {
/**
* goal : Create an empty diagram group attached to one engine.
* pre : engine is a DiagramEngine and options contains only presentation data.
* post : The group has no members and has not yet rendered a frame.
* result : A DiagramGroup instance.
* internals : Membership is kept as a Set so nested groups and repeated add
* operations remain deterministic; bounds are calculated from member handles.
*/
constructor(engine, options = {}) {
if (!engine) throw new TypeError("A diagram engine is required");
this.engine = engine;
this.label = String(options.label || "");
this.className = String(options.className || "cmap-group-frame");
this.backgroundColor = options.backgroundColor || "transparent";
this.borderColor = options.borderColor || "#5d6d7e";
this.padding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 24;
this.depth = Number.isFinite(Number(options.depth)) ? Number(options.depth) : 0;
this.expanded = options.expanded !== false;
this.manageVisibility = options.manageVisibility !== false;
this.members = new Set();
this.elementValue = null;
this.onToggleHandler = null;
this.onPointerDownHandler = options.onPointerDown || null;
this.onDoubleClickHandler = options.onDoubleClick || null;
}
/**
* goal : Add one node or nested group to this group.
* pre : member belongs to the same DiagramEngine.
* post : The member contributes to group bounds and rendering.
* result : This group, for fluent setup.
*/
add(member) {
if (!(member instanceof DiagramComponent) && !(member instanceof DiagramGroup)) {
throw new TypeError("A diagram group member is required");
}
if (member.engine !== this.engine) throw new TypeError("Group member belongs to another engine");
this.members.add(member);
this.redraw();
return this;
}
/** Remove a member and update the frame. */
remove(member) {
this.members.delete(member);
this.redraw();
return this;
}
/** Register the callback invoked when the generic frame toggle is clicked. */
onToggle(handler) {
if (handler !== null && handler !== undefined && typeof handler !== "function") {
throw new TypeError("Invalid group toggle handler");
}
this.onToggleHandler = handler || null;
return this;
}
/**
* goal : Set the visual appearance of the group frame.
* pre : values contains optional CSS colour values.
* post : The next redraw uses the supplied background and border colours.
* result : This group, for fluent setup.
* internals : Values are kept on the group and applied as CSS custom
* properties in redraw(), allowing application-specific frame styles.
*/
setAppearance(values = {}) {
if (values.backgroundColor !== undefined) this.backgroundColor = String(values.backgroundColor);
if (values.borderColor !== undefined) this.borderColor = String(values.borderColor);
if (values.label !== undefined) this.label = String(values.label);
this.redraw();
return this;
}
/**
* goal : Change whether group contents are shown.
* pre : expanded is boolean-coercible.
* post : The frame and all member components reflect the new state.
* result : The resulting expanded state.
* internals : A collapsed group hides direct members; nested groups redraw
* themselves so their own frames disappear with the enclosing group.
*/
setExpanded(expanded) {
this.expanded = Boolean(expanded);
if (this.manageVisibility) {
for (const member of this.members) {
if (member instanceof DiagramGroup) member.setExpanded(this.expanded && member.expanded);
else member.visible(this.expanded);
}
}
this.redraw();
return this.expanded;
}
/** Return the union bounds of visible member nodes and nested groups. */
bounds() {
const members = [...this.members]
.map((member) => member instanceof DiagramGroup ? member.bounds() :
(member.visible() ? this.componentBounds(member) : null))
.filter(Boolean);
if (!members.length) return null;
return {
left: Math.min(...members.map((bounds) => bounds.left)) - this.padding,
top: Math.min(...members.map((bounds) => bounds.top)) - this.padding,
right: Math.max(...members.map((bounds) => bounds.right)) + this.padding,
bottom: Math.max(...members.map((bounds) => bounds.bottom)) + this.padding
};
}
/** Return the DOM frame, if the group currently has one. */
element() {
return this.elementValue;
}
/**
* Render or remove the generic frame according to membership and state.
* The engine calls this after component redraws; applications may call it
* directly after changing group presentation or membership.
*/
redraw() {
const surface = this.engine.surfaceElement();
const bounds = this.expanded && this.bounds();
if (!surface || !bounds) {
this.removeElement();
return this;
}
if (!this.elementValue) {
const frame = document.createElement("div");
frame.className = this.className;
frame.setAttribute("role", "group");
for (const side of ["top", "right", "bottom", "left"]) {
const dragEdge = document.createElement("div");
dragEdge.className = `${this.className}-drag-edge ${this.className}-drag-edge-${side}`;
if (this.onPointerDownHandler) dragEdge.addEventListener("pointerdown", this.onPointerDownHandler);
if (this.onDoubleClickHandler) dragEdge.addEventListener("dblclick", this.onDoubleClickHandler);
frame.append(dragEdge);
}
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = `${this.className}-toggle`;
toggle.addEventListener("click", () => {
this.setExpanded(!this.expanded);
if (this.onToggleHandler) this.onToggleHandler(this, this.expanded);
});
frame.append(toggle);
surface.prepend(frame);
this.elementValue = frame;
}
const toggle = this.elementValue.querySelector(`.${this.className}-toggle`);
if (toggle) {
toggle.textContent = this.expanded ? "-" : "+";
toggle.setAttribute("aria-expanded", String(this.expanded));
toggle.setAttribute("aria-label", this.expanded ? "Collapse group" : "Expand group");
}
this.elementValue.setAttribute("aria-label", this.label);
Object.assign(this.elementValue.style, {
left: `${bounds.left}px`,
top: `${bounds.top}px`,
width: `${bounds.right - bounds.left}px`,
height: `${bounds.bottom - bounds.top}px`,
zIndex: String(this.depth)
});
this.elementValue.style.setProperty("--rw-cmap-submap-background", this.backgroundColor);
this.elementValue.style.setProperty("--rw-cmap-submap-border", this.borderColor);
return this;
}
/** Remove the frame and all references owned by this group. */
destroy() {
for (const member of this.members) {
if (member instanceof DiagramGroup) member.destroy();
}
this.members.clear();
this.removeElement();
}
componentBounds(component) {
const attributes = component.attr();
const x = Number(attributes.x);
const y = Number(attributes.y);
const width = Number(attributes.width);
const height = Number(attributes.height);
if (![x, y, width, height].every(Number.isFinite)) return null;
return { left: x, top: y, right: x + width, bottom: y + height };
}
removeElement() {
if (this.elementValue) {
this.elementValue.remove();
this.elementValue = null;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import {
DiagramComponent,
pickAttributes,
validateOptionalHandler
} from "./diagram-component.js";
import { DrawingLink, DrawingSurface, DrawingTriple } from "./drawing-core.js";
import { DiagramNode } from "./diagram-node.js";
const LINK_ATTRIBUTES = [
"content",
"contentType",
"cx",
"cy",
"width",
"height",
"backgroundColor",
"borderColor",
"borderWidth",
"textColor",
"sourceX",
"sourceY",
"targetX",
"targetY",
"lineColor",
"lineWidth",
"hasArrow"
];
/**
* Public rendering handle for one diagram link.
*
* A link owns only visual and interaction state. Its source and target are
* DiagramNode instances from the same DiagramEngine.
*/
export class DiagramLink extends DiagramComponent {
constructor(engine, attributes) {
const component = new DrawingLink(pickAttributes(attributes, LINK_ATTRIBUTES));
super(engine, component, LINK_ATTRIBUTES);
}
/** Read or replace the source node. Pass null to disconnect it. */
sourceNode(node) {
return this.connectNode(DrawingSurface.CONNECTION_TYPE_SOURCE, node);
}
/** Read or replace the target node. Pass null to disconnect it. */
targetNode(node) {
return this.connectNode(DrawingSurface.CONNECTION_TYPE_TARGET, node);
}
/** Register a callback for a source or target connection change. */
onConnectionChange(handler) {
validateOptionalHandler(handler, "connection-change");
this.component.connectionChangeHandler = handler ? (type, node, event) => {
handler(this, type, node ? this.engine.handleFor(node) : null, event);
} : null;
return this;
}
/** Straighten both link segments between their current endpoints. */
straighten() {
const relation = this.component.relations()
.find((candidate) => candidate instanceof DrawingTriple);
const sourceNode = relation ? relation.sourceNode() : null;
const targetNode = relation ? relation.targetNode() : null;
if (!sourceNode || !targetNode) {
this.component.straighten();
return this;
}
const sourcePoint = relation.connectedPoint(
sourceNode, targetNode.cx(), targetNode.cy());
const targetPoint = relation.connectedPoint(
targetNode, sourceNode.cx(), sourceNode.cy());
this.component.straighten(
sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
return this;
}
/** Read or update one connected endpoint. */
connectNode(type, node) {
const connectedComponent = this.engine.drawingSurface
.connectedNode(type, this.component);
if (node === undefined) {
return connectedComponent ? this.engine.handleFor(connectedComponent) : null;
}
if (node !== null && !this.validateNode(type, node)) return this;
if (connectedComponent) {
this.engine.drawingSurface.disconnect(type, connectedComponent, this.component);
}
if (node !== null) {
this.engine.drawingSurface.connect(type, node.component, this.component);
}
return this;
}
/** Ensure an endpoint belongs to this engine and is not used at both ends. */
validateNode(type, node) {
if (!(node instanceof DiagramNode) || node.engine !== this.engine) {
throw new TypeError("Invalid diagram node");
}
const otherType = DrawingSurface.anotherConnectionType(type);
const otherNode = this.engine.drawingSurface.connectedNode(otherType, this.component);
if (otherNode === node.component) {
return false;
}
return true;
}
}
+48
View File
@@ -0,0 +1,48 @@
import {
DiagramComponent,
pickAttributes,
validateOptionalHandler
} from "./diagram-component.js";
import { DrawingNode } from "./drawing-core.js";
const NODE_ATTRIBUTES = [
"content",
"contentType",
"x",
"y",
"width",
"height",
"backgroundColor",
"borderColor",
"borderWidth",
"textColor"
];
/**
* Public rendering handle for one diagram node.
*
* DiagramEngine creates nodes and owns their lifetime. The editor uses this
* handle to update presentation attributes and receive completed moves.
*/
export class DiagramNode extends DiagramComponent {
constructor(engine, attributes) {
const component = new DrawingNode(pickAttributes(attributes, NODE_ATTRIBUTES));
super(engine, component, NODE_ATTRIBUTES);
}
/** Constrain or observe the node while it is being dragged. */
onMove(handler) {
validateOptionalHandler(handler, "move");
this.component.moveHandler = handler ?
(x, y) => handler(this, x, y) : null;
return this;
}
/** Register a callback for the end of a node drag gesture. */
onMoveEnd(handler) {
validateOptionalHandler(handler, "move-end");
this.component.moveEndHandler = handler ?
(x, y, event) => handler(this, x, y, event) : null;
return this;
}
}
@@ -0,0 +1,86 @@
/**
* Maintain renderer component order and coordinate hit-test priority.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
import { Component, helper } from "./drawing-support.js";
class ComponentList extends helper.List {
constructor() {
super();
}
toFront(component) {
var data = this.data;
var index = data.indexOf(component);
if (index === -1)
return;
data.splice(index, 1);
data.push(component);
}
fromPoint(ctor, x, y) {
var data = this.data;
// The visual stack is connector controls, concepts and finally relations.
// Use that same priority for coordinate hit testing so a relation that is
// hidden behind a concept can never steal the concept's click.
var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
var tolerance = toleranceIndex === 0 ? 0 : 8;
for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
for (var i = data.length - 1; i >= 0; i--) {
var component = data[i];
if (!(component instanceof types[typeIndex]))
continue;
if (component.visible === false)
continue;
if (component.contains(x, y, tolerance))
return component;
}
}
}
return null;
}
}
class DisabledConnectorList extends helper.List {
constructor() {
super();
}
add(type, link) {
super.add( {
type: type,
link: link
});
}
remove(type, link) {
super.remove( {
type: type,
link: link
});
}
contains(type, link) {
return super.contains( {
type: type,
link: link
});
}
equal(a, b) {
return a.type === b.type && a.link === b.link;
}
}
export { ComponentList, DisabledConnectorList };
+524
View File
@@ -0,0 +1,524 @@
/**
* Render the node, link and connector primitives of a diagram.
*
* These classes contain geometry and DOM presentation only. Application code
* reaches them through DiagramNode and DiagramLink handles.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { Component, dom, helper } from "./drawing-support.js";
class Node extends Component {
constructor(props) {
super();
this.visible = true;
this.content = this.prop(props.content, '', helper.toString);
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
this.x = this.prop(props.x, 0, helper.toNumber);
this.y = this.prop(props.y, 0, helper.toNumber);
this.width = this.prop(props.width, 75, helper.toNumber);
this.height = this.prop(props.height, 30, helper.toNumber);
this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString);
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
this.textColor = this.prop(props.textColor, '#333', helper.toString);
this.zIndex = this.prop('auto');
this.element = this.prop(null);
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
this.moveHandler = null;
}
cx() {
return this.x() + this.width() / 2;
}
cy() {
return this.y() + this.height() / 2;
}
borderRadius() {
return 4;
}
contains(x, y, tolerance) {
var nx = this.x();
var ny = this.y();
var nwidth = this.width();
var nheight = this.height();
return (nx - tolerance <= x && x <= nx + nwidth + tolerance &&
ny - tolerance <= y && y <= ny + nheight + tolerance);
}
style() {
var contentType = this.contentType();
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)';
var borderWidthOffset = this.borderWidth() * 2;
return {
backgroundColor: this.backgroundColor(),
border: this.borderWidth() + 'px solid ' + this.borderColor(),
borderRadius: this.borderRadius() + 'px',
color: this.textColor(),
display: this.visible ? '' : 'none',
height: (this.height() - borderWidthOffset) + 'px',
lineHeight: (lineHeight - borderWidthOffset) + 'px',
msTransform: translate,
overflow: 'hidden',
pointerEvents: 'auto',
position: 'absolute',
textAlign: textAlign,
textOverflow: 'ellipsis',
transform: translate,
webkitTransform: translate,
whiteSpace: 'nowrap',
width: (this.width() - borderWidthOffset) + 'px',
zIndex: this.zIndex()
};
}
redraw() {
var element = this.element();
var parentElement = this.parentElement();
if (!parentElement && !element)
return;
// add element
if (parentElement && !element) {
element = dom.el('<div>');
this.element(element);
dom.append(parentElement, element);
this.redraw();
return;
}
// remove element
if (!parentElement && element) {
dom.remove(element);
this.element(null);
this.cache({});
return;
}
var cache = this.cache();
// update element
var content = this.content();
if (content !== cache.content) {
var contentType = this.contentType();
if (contentType === helper.CONTENT_TYPE_TEXT)
dom.text(element, content);
else if (contentType === helper.CONTENT_TYPE_HTML)
dom.html(element, content);
cache.content = content;
}
var style = this.style();
dom.css(element, helper.diffObj(style, cache.style));
cache.style = style;
this.notifyRendered();
}
}
class Link extends Component {
constructor(props) {
super();
this.visible = true;
this.content = this.prop(props.content, '', helper.toString);
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
this.cx = this.prop(props.cx, 100, helper.toNumber);
this.cy = this.prop(props.cy, 40, helper.toNumber);
this.width = this.prop(props.width, 50, helper.toNumber);
this.height = this.prop(props.height, 20, helper.toNumber);
this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString);
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
this.textColor = this.prop(props.textColor, '#333', helper.toString);
this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber);
this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber);
this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber);
this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber);
this.lineColor = this.prop(props.lineColor, '#333', helper.toString);
this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber);
this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean);
this.zIndex = this.prop('auto');
this.element = this.prop(null);
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
this.connectionChangeHandler = null;
}
straighten(sx, sy, tx, ty) {
if (arguments.length === 0) {
this.cx((this.sourceX() + this.targetX()) / 2);
this.cy((this.sourceY() + this.targetY()) / 2);
return;
}
this.cx((sx + tx) / 2);
this.cy((sy + ty) / 2);
this.sourceX(sx);
this.sourceY(sy);
this.targetX(tx);
this.targetY(ty);
}
contains(x, y, tolerance) {
var content = this.content();
var lcx = this.cx();
var lcy = this.cy();
// content area
if (content) {
var lwidth = this.width();
var lheight = this.height();
var lx = lcx - lwidth / 2;
var ly = lcy - lheight / 2;
if (lx - tolerance <= x && x <= lx + lwidth + tolerance &&
ly - tolerance <= y && y <= ly + lheight + tolerance) {
return true;
}
}
var lineWidth = this.lineWidth();
// source path
if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
return true;
// target path
if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
return true;
return false;
}
containsPath(x0, y0, x1, y1, x, y, d) {
var ax = x1 - x0;
var ay = y1 - y0;
var bx = x - x0;
var by = y - y0;
var r = (ax * bx + ay * by) / (ax * ax + ay * ay);
if (0 <= r && r <= 1) {
var px = x0 + r * ax;
var py = y0 + r * ay;
var dx = px - x;
var dy = py - y;
if (dx * dx + dy * dy <= d * d)
return true;
}
return false;
}
style() {
return {
display: this.visible ? '' : 'none',
pointerEvents: 'none',
position: 'absolute',
zIndex: this.zIndex()
};
}
pathContainerStyle() {
var width = Math.max(this.cx(), this.sourceX(), this.targetX());
var height = Math.max(this.cy(), this.sourceY(), this.targetY());
return {
height: height + 'px',
overflow: 'visible',
position: 'absolute',
width: width + 'px'
};
}
lineAttributes() {
var d = [
'M', this.sourceX(), this.sourceY(),
'L', this.cx(), this.cy(),
'L', this.targetX(), this.targetY()
].join(' ');
return {
d: d,
fill: 'none',
stroke: this.lineColor(),
'stroke-linecap': 'round',
'stroke-width': this.lineWidth()
};
}
arrowAttributes() {
var cx = this.cx();
var cy = this.cy();
var tx = this.targetX();
var ty = this.targetY();
var radians = Math.atan2(ty - cy, tx - cx);
var p0 = {
x: 15 * Math.cos(radians - 26 * Math.PI / 180),
y: 15 * Math.sin(radians - 26 * Math.PI / 180)
};
var p1 = {
x: 15 * Math.cos(radians + 26 * Math.PI / 180),
y: 15 * Math.sin(radians + 26 * Math.PI / 180)
};
var p2 = {
x: 7 * Math.cos(radians),
y: 7 * Math.sin(radians)
};
var d = [
'M', tx - p0.x, ty - p0.y,
'L', tx, ty,
'L', tx - p1.x, ty - p1.y,
'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y,
'Z'
].join(' ');
return {
d: d,
fill: this.lineColor(),
stroke: this.lineColor(),
'stroke-linejoin': 'round',
'stroke-width': this.lineWidth(),
visibility: this.hasArrow() ? 'visible' : 'hidden'
};
}
contentStyle() {
var contentType = this.contentType();
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
var x = this.cx() - this.width() / 2;
var y = this.cy() - this.height() / 2;
var translate = 'translate(' + x + 'px, ' + y + 'px)';
var borderWidthOffset = this.borderWidth() * 2;
return {
backgroundColor: this.backgroundColor(),
border: this.borderWidth() + 'px solid ' + this.borderColor(),
borderRadius: '4px',
color: this.textColor(),
height: (this.height() - borderWidthOffset) + 'px',
lineHeight: (lineHeight - borderWidthOffset) + 'px',
msTransform: translate,
overflow: 'hidden',
position: 'absolute',
textAlign: textAlign,
textOverflow: 'ellipsis',
transform: translate,
visibility: this.content() ? 'visible' : 'hidden',
webkitTransform: translate,
whiteSpace: 'nowrap',
width: (this.width() - borderWidthOffset) + 'px'
};
}
redraw() {
var element = this.element();
var parentElement = this.parentElement();
if (!parentElement && !element)
return;
// add element
if (parentElement && !element) {
element = dom.el('<div>');
dom.html(element, '<svg><path></path><path></path></svg><div></div>');
this.element(element);
dom.append(parentElement, element);
this.redraw();
return;
}
// remove element
if (!parentElement && element) {
dom.remove(element);
this.element(null);
this.cache({});
return;
}
var cache = this.cache();
// update path container element
var pathContainerStyle = this.pathContainerStyle();
var pathContainerElement = dom.child(element, 0);
dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle));
cache.pathContainerElementStyle = contentStyle;
// update line element
var lineAttributes = this.lineAttributes();
var lineElement = dom.child(pathContainerElement, 0);
dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes));
cache.lineAttributes = lineAttributes;
// update arrow element
var arrowAttributes = this.arrowAttributes();
var arrowElement = dom.child(pathContainerElement, 1);
dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes));
cache.arrowAttributes = arrowAttributes;
// update content element
var content = this.content();
var contentStyle = this.contentStyle();
var contentElement = dom.child(element, 1);
if (content !== cache.content) {
var contentType = this.contentType();
if (contentType === helper.CONTENT_TYPE_TEXT)
dom.text(contentElement, content);
else if (contentType === helper.CONTENT_TYPE_HTML)
dom.html(contentElement, content);
cache.content = content;
}
dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle));
cache.contentStyle = contentStyle;
// update container element
var style = this.style();
dom.css(element, helper.diffObj(style, cache.style));
cache.style = style;
this.notifyRendered();
}
}
class Connector extends Component {
constructor(props) {
super();
this.x = this.prop(props.x, 0, helper.toNumber);
this.y = this.prop(props.y, 0, helper.toNumber);
this.color = this.prop(Connector.COLOR_UNCONNECTED);
this.zIndex = this.prop('auto');
this.element = this.prop(null);
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
}
r() {
return 16;
}
contains(x, y, tolerance) {
var dx = x - this.x();
var dy = y - this.y();
var r = this.r() + tolerance;
return (dx * dx + dy * dy <= r * r);
}
style() {
var r = this.r();
var x = this.x() - r;
var y = this.y() - r;
var translate = 'translate(' + x + 'px, ' + y + 'px)';
return {
backgroundColor: this.color(),
border: '2px solid lightgray',
borderRadius: '50%',
boxSizing: 'border-box',
height: r * 2 + 'px',
msTransform: translate,
opacity: 0.6,
pointerEvents: 'none',
position: 'absolute',
transform: translate,
webkitTransform: translate,
width: r * 2 + 'px',
zIndex: this.zIndex()
};
}
redraw() {
var element = this.element();
var parentElement = this.parentElement();
if (!parentElement && !element)
return;
// add element
if (parentElement && !element) {
element = dom.el('<div>');
this.element(element);
dom.append(parentElement, element);
this.redraw();
return;
}
// remove element
if (!parentElement && element) {
dom.remove(element);
this.element(null);
return;
}
var cache = this.cache();
// update element
var style = this.style();
dom.css(element, helper.diffObj(style, cache.style));
cache.style = style;
this.notifyRendered();
}
}
Connector.COLOR_CONNECTED = 'lightgreen';
Connector.COLOR_UNCONNECTED = 'pink';
export { Connector, Link as DrawingLink, Node as DrawingNode };
+9
View File
@@ -0,0 +1,9 @@
/**
* Internal exports of the modular diagram rendering core.
*
* The implementation is derived from the MIT-licensed ionstage/cmap 0.1.3
* renderer and is maintained as part of racket-wiki.
*/
export { DrawingLink, DrawingNode } from "./drawing-components.js";
export { DrawingTriple } from "./drawing-relations.js";
export { DrawingSurface } from "./drawing-surface.js";
+308
View File
@@ -0,0 +1,308 @@
/**
* Keep diagram endpoints and connector controls geometrically related.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
class Relation {
constructor() {
}
prop(initialValue) {
var cache = initialValue;
return function(value) {
if (typeof value === 'undefined')
return cache;
cache = value;
};
}
update() {
}
}
class Triple extends Relation {
constructor(props) {
super();
this.link = this.prop(props.link);
this.sourceNode = this.prop(props.sourceNode || null);
this.targetNode = this.prop(props.targetNode || null);
this.skipNextUpdate = this.prop(false);
this.nodePositionsCache = this.prop({});
}
update(changedComponent) {
if (this.skipNextUpdate()) {
this.skipNextUpdate(false);
return;
}
var link = this.link();
var sourceNode = this.sourceNode();
var targetNode = this.targetNode();
if (changedComponent instanceof Node)
this.updateNode(link, sourceNode, targetNode, changedComponent);
else if (changedComponent instanceof Link)
this.updateLink(link, sourceNode, targetNode);
}
updateNode(link, sourceNode, targetNode, changedNode) {
if (sourceNode && targetNode)
this.rotateLink(link, sourceNode, targetNode, changedNode);
else
this.shiftLink(link, sourceNode, targetNode, changedNode);
this.updateNodePositionsCache();
}
rotateLink(link, sourceNode, targetNode, changedNode) {
var cache = this.nodePositionsCache();
var sncx = cache.sncx;
var sncy = cache.sncy;
var tncx = cache.tncx;
var tncy = cache.tncy;
var lcx = link.cx();
var lcy = link.cy();
var ts_dx = tncx - sncx;
var ts_dy = tncy - sncy;
var cs_dx = lcx - sncx;
var cs_dy = lcy - sncy;
var ts_rad0 = Math.atan2(ts_dy, ts_dx);
var cs_rad0 = Math.atan2(cs_dy, cs_dx);
// changed node position
if (changedNode === sourceNode) {
sncx = sourceNode.cx();
sncy = sourceNode.cy();
} else if (changedNode === targetNode) {
tncx = targetNode.cx();
tncy = targetNode.cy();
}
// center positions of two nodes are equal
if (cs_rad0 === 0) {
link.cx((sncx + tncx) / 2);
link.cy((sncy + tncy) / 2);
return;
}
var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy);
var ts_cs_rad = ts_rad0 - cs_rad0;
ts_dx = tncx - sncx;
ts_dy = tncy - sncy;
var ts_rad1 = Math.atan2(ts_dy, ts_dx);
var cs_rad1 = ts_rad1 - ts_cs_rad;
var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1;
var cs_d1 = cs_d0 * d_rate;
lcx = sncx + cs_d1 * Math.cos(cs_rad1);
lcy = sncy + cs_d1 * Math.sin(cs_rad1);
link.cx(lcx);
link.cy(lcy);
}
shiftLink(link, sourceNode, targetNode, changedNode) {
var cache = this.nodePositionsCache();
var ncx = changedNode.cx();
var ncy = changedNode.cy();
if (changedNode === sourceNode) {
link.targetX(link.targetX() + (ncx - cache.sncx));
link.targetY(link.targetY() + (ncy - cache.sncy));
} else if (changedNode === targetNode) {
link.sourceX(link.sourceX() + (ncx - cache.tncx));
link.sourceY(link.sourceY() + (ncy - cache.tncy));
}
}
updateLink(link, sourceNode, targetNode) {
var lx, ly, p;
if (sourceNode) {
// connect link to source node
lx = targetNode ? link.cx() : link.targetX();
ly = targetNode ? link.cy() : link.targetY();
p = this.connectedPoint(sourceNode, lx, ly);
link.sourceX(p.x);
link.sourceY(p.y);
}
if (targetNode) {
// connect link to target node
lx = sourceNode ? link.cx() : link.sourceX();
ly = sourceNode ? link.cy() : link.sourceY();
p = this.connectedPoint(targetNode, lx, ly);
link.targetX(p.x);
link.targetY(p.y);
}
if (!sourceNode || !targetNode) {
// link content moves to midpoint
link.cx((link.sourceX() + link.targetX()) / 2);
link.cy((link.sourceY() + link.targetY()) / 2);
}
}
updateLinkAngle(radians) {
var link = this.link();
var sourceNode = this.sourceNode();
var targetNode = this.targetNode();
var ldx = link.targetX() - link.sourceX();
var ldy = link.targetY() - link.sourceY();
var d = Math.sqrt(ldx * ldx + ldy * ldy);
var connectedNode = sourceNode || targetNode;
var cx = connectedNode.cx();
var cy = connectedNode.cy();
var lx = cx + d * Math.cos(radians);
var ly = cy + d * Math.sin(radians);
var p = this.connectedPoint(connectedNode, lx, ly);
if (connectedNode === sourceNode)
link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy);
else if (connectedNode === targetNode)
link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y);
}
updateNodePositionsCache() {
var sourceNode = this.sourceNode();
var targetNode = this.targetNode();
var cache = this.nodePositionsCache();
if (sourceNode) {
cache.sncx = sourceNode.cx();
cache.sncy = sourceNode.cy();
}
if (targetNode) {
cache.tncx = targetNode.cx();
cache.tncy = targetNode.cy();
}
}
connectedPoint(node, lx, ly) {
var nx = node.x();
var ny = node.y();
var nwidth = node.width();
var nheight = node.height();
var ncx = node.cx();
var ncy = node.cy();
var alpha = Math.atan2(ly - ncy, lx - ncx);
var beta = Math.PI / 2 - alpha;
var t = Math.atan2(nheight, nwidth);
var x, y;
// left edge
if (alpha < t - Math.PI || alpha > Math.PI - t) {
x = nx;
y = ncy - nwidth * Math.tan(alpha) / 2;
}
// top edge
else if (alpha < -t) {
x = ncx - nheight * Math.tan(beta) / 2;
y = ny;
}
// right edge
else if (alpha < t) {
x = nx + nwidth;
y = ncy + nwidth * Math.tan(alpha) / 2;
}
// bottom edge
else {
x = ncx + nheight * Math.tan(beta) / 2;
y = ny + nheight;
}
var x0, y0, l, ex, ey;
var r = node.borderRadius();
var atCorner = false;
// top-left corner
if (x < nx + r && y < ny + r) {
x0 = nx + r;
y0 = ny + r;
atCorner = true;
}
// top-right corner
else if (x > nx + nwidth - r && y < ny + r) {
x0 = nx + nwidth - r;
y0 = ny + r;
atCorner = true;
}
// bottom-left corner
else if (x < nx + r && y > ny + nheight - r) {
x0 = nx + r;
y0 = ny + nheight - r;
atCorner = true;
}
// bottom-right corner
else if (x > nx + nwidth - r && y > ny + nheight - r) {
x0 = nx + nwidth - r;
y0 = ny + nheight - r;
atCorner = true;
}
if (atCorner) {
l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y));
ex = (x0 - x) / l;
ey = (y0 - y) / l;
x = x0 - r * ex;
y = y0 - r * ey;
}
return {
x: x,
y: y
};
}
}
class LinkConnectorRelation extends Relation {
constructor(props) {
super();
this.type = this.prop(props.type);
this.link = this.prop(props.link);
this.connector = this.prop(props.connector);
}
isConnected(isConnected) {
var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED;
this.connector().color(color);
}
update(changedComponent) {
var type = this.type();
var link = this.link();
var connector = this.connector();
if (changedComponent === link) {
connector.x(link[type + 'X']());
connector.y(link[type + 'Y']());
}
}
}
export { LinkConnectorRelation, Triple as DrawingTriple };
+340
View File
@@ -0,0 +1,340 @@
/**
* Shared DOM and state support for the low-level diagram renderer.
*
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
const CONTENT_TYPE_TEXT = 'text';
const CONTENT_TYPE_HTML = 'html';
class ItemList {
constructor() {
this.data = [];
}
add(item) {
if (!this.contains(item)) this.data.push(item);
}
remove(item) {
for (let index = this.data.length - 1; index >= 0; index -= 1) {
if (this.equal(this.data[index], item)) {
this.data.splice(index, 1);
break;
}
}
}
contains(item) {
return this.data.some((candidate) => this.equal(candidate, item));
}
equal(first, second) {
return first === second;
}
toArray() {
return this.data.slice();
}
}
const helper = {
CONTENT_TYPE_HTML,
CONTENT_TYPE_TEXT,
List: ItemList,
toNumber(value, defaultValue) {
return !isNaN(value) ? Number(value) : defaultValue;
},
toString(value, defaultValue) {
return value !== undefined ? String(value) : defaultValue;
},
toBoolean(value, defaultValue) {
return value !== undefined ? Boolean(value) : defaultValue;
},
toContentType(value, defaultValue) {
if (value === CONTENT_TYPE_TEXT || value === CONTENT_TYPE_HTML) return value;
return defaultValue;
},
eachInstance(values, constructor, callback) {
values.filter((value) => value instanceof constructor).forEach(callback);
},
firstInstance(values, constructor) {
return values.find((value) => value instanceof constructor);
},
diffObj(newObject, oldObject) {
const difference = {};
for (const key in newObject) {
if (!oldObject || newObject[key] !== oldObject[key]) {
difference[key] = newObject[key];
}
}
return difference;
},
identity(value) {
return value;
}
};
var dom = {};
dom.disabled = function() {
return (typeof document === 'undefined');
};
dom.el = function(selector) {
if (selector.charAt(0) === '<') {
selector = selector.match(/<(.+)>/)[1];
return document.createElement(selector);
}
};
dom.body = function() {
return document.body;
};
dom.attr = function(el, props) {
for (var key in props) {
el.setAttribute(key, props[key]);
}
};
dom.css = function(el, props) {
var style = el.style;
for (var key in props) {
style[key] = props[key];
}
};
dom.rect = function(el) {
return el.getBoundingClientRect();
};
dom.clientWidth = function(el) {
return el.clientWidth;
};
dom.clientHeight = function(el) {
return el.clientHeight;
};
dom.scrollLeft = function(el) {
return el.scrollLeft;
};
dom.scrollTop = function(el) {
return el.scrollTop;
};
dom.scrollWidth = function(el) {
return el.scrollWidth;
};
dom.scrollHeight = function(el) {
return el.scrollHeight;
};
dom.text = function(el, s) {
el.textContent = s;
};
dom.html = function(el, s) {
el.innerHTML = s;
};
dom.append = function(parent, el) {
parent.appendChild(el);
};
dom.remove = function(el) {
el.parentNode.removeChild(el);
};
dom.child = function(el, index) {
return el.childNodes[index];
};
dom.animate = function(callback) {
return window.requestAnimationFrame(callback);
};
dom.supportsTouch = function() {
return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch));
};
dom.on = function(el, type, listener) {
el.addEventListener(type, listener);
};
dom.off = function(el, type, listener) {
el.removeEventListener(type, listener);
};
dom.pagePoint = function(event, offset) {
if (dom.supportsTouch())
event = event.changedTouches[0];
return {
x: event.pageX - (offset ? offset.x : 0),
y: event.pageY - (offset ? offset.y : 0)
};
};
dom.clientPoint = function(event, offset) {
if (dom.supportsTouch())
event = event.changedTouches[0];
return {
x: event.clientX - (offset ? offset.x : 0),
y: event.clientY - (offset ? offset.y : 0)
};
};
dom.cancel = function(event) {
event.preventDefault();
};
class Draggable {
constructor(element, onStart, onMove, onEnd) {
this.element = element;
this.onStart = onStart;
this.onMove = onMove;
this.onEnd = onEnd;
this.start = this.start.bind(this);
this.move = this.move.bind(this);
this.end = this.end.bind(this);
this.locked = false;
this.startingPoint = null;
this.startEvent = dom.supportsTouch() ? 'touchstart' : 'mousedown';
this.moveEvent = dom.supportsTouch() ? 'touchmove' : 'mousemove';
this.endEvent = dom.supportsTouch() ? 'touchend' : 'mouseup';
dom.on(this.element, this.startEvent, this.start);
}
start(event) {
if (this.locked)
return;
this.locked = true;
this.startingPoint = dom.pagePoint(event);
const rectangle = dom.rect(this.element);
const point = dom.clientPoint(event, {
x: rectangle.left - dom.scrollLeft(this.element),
y: rectangle.top - dom.scrollTop(this.element)
});
if (typeof this.onStart === 'function') this.onStart(point.x, point.y, event);
dom.on(document, this.moveEvent, this.move);
dom.on(document, this.endEvent, this.end);
}
move(event) {
const distance = dom.pagePoint(event, this.startingPoint);
if (typeof this.onMove === 'function') this.onMove(distance.x, distance.y, event);
}
end(event) {
dom.off(document, this.moveEvent, this.move);
dom.off(document, this.endEvent, this.end);
const distance = dom.pagePoint(event, this.startingPoint);
if (typeof this.onEnd === 'function') this.onEnd(distance.x, distance.y, event);
this.locked = false;
}
}
dom.draggable = function(element, onStart, onMove, onEnd) {
if (dom.disabled()) return null;
return new Draggable(element, onStart, onMove, onEnd);
};
const dirtyComponents = [];
let renderRequestId = null;
class Component {
constructor() {
this.disposed = false;
}
dispose() {
this.disposed = true;
}
prop(initialValue, defaultValue, converter) {
const convert = typeof converter === 'function' ? converter : helper.identity;
let cache = convert(initialValue, defaultValue);
return (value) => {
if (typeof value === 'undefined')
return cache;
if (value === cache)
return;
cache = convert(value, cache);
this.markDirty();
};
}
relations() {
return [];
}
redraw() {}
notifyRendered() {
if (typeof this.renderedHandler === 'function' && this.element())
this.renderedHandler(this.element());
}
markDirty() {
if (dom.disabled() || this.disposed)
return;
if (!dirtyComponents.includes(this))
dirtyComponents.push(this);
if (renderRequestId !== null)
return;
renderRequestId = dom.animate(redrawDirtyComponents);
}
}
function updateDirtyRelations(index) {
const initialLength = dirtyComponents.length;
for (let position = index; position < initialLength; position += 1) {
const component = dirtyComponents[position];
if (component.disposed)
continue;
component.relations().forEach((relation) => {
if (!relation.disposed)
relation.update(component);
});
}
if (dirtyComponents.length > initialLength)
updateDirtyRelations(initialLength);
}
function redrawDirtyComponents() {
updateDirtyRelations(0);
dirtyComponents.forEach((component) => {
if (!component.disposed)
component.redraw();
});
dirtyComponents.length = 0;
renderRequestId = null;
}
export { Component, dom, helper };
+615
View File
@@ -0,0 +1,615 @@
/**
* Own the diagram surface, component relations and pointer interaction.
*
* DrawingSurface is internal to DiagramEngine. It coordinates the primitive
* renderers, performs hit testing and translates drag gestures to geometry.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { ComponentList, DisabledConnectorList } from "./drawing-collections.js";
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
import { DrawingTriple as Triple, LinkConnectorRelation } from "./drawing-relations.js";
import { Component, dom, helper } from "./drawing-support.js";
class Cmap extends Component {
constructor(rootElement) {
super();
this.componentList = this.prop(new ComponentList());
this.disabledConnectorList = this.prop(new DisabledConnectorList());
this.dragDisabledComponentList = this.prop(new ComponentList());
this.element = this.prop(null);
this.rootElement = this.prop(rootElement || null);
this.retainerElement = this.prop(null);
this.dragContext = this.prop({});
this.selectionHandler = null;
this.activationHandler = null;
this.lastClickComponent = null;
this.lastClickTime = 0;
this.zoomFactor = 1;
this.markDirty();
}
add(component) {
component.parentElement(this.element());
this.componentList().add(component);
this.updateZIndex();
}
static anotherConnectionType(type) {
if (type === Cmap.CONNECTION_TYPE_SOURCE)
return Cmap.CONNECTION_TYPE_TARGET;
else if (type === Cmap.CONNECTION_TYPE_TARGET)
return Cmap.CONNECTION_TYPE_SOURCE;
}
remove(component) {
component.parentElement(null);
if (component instanceof Link)
this.hideConnectors(component);
this.disconnect(component);
this.componentList().remove(component);
this.updateZIndex();
}
toFront(component) {
this.componentList().toFront(component);
this.updateZIndex();
}
updateZIndex() {
var linkIndex = 0;
var nodeIndex = 0;
this.componentList().toArray().forEach(function(component) {
if (component instanceof Connector)
return;
// Relations always occupy a lower band than concept nodes. Reordering a
// selected component therefore only changes its order inside that band.
var zIndex = component instanceof Link ?
Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
component.zIndex(zIndex);
if (!(component instanceof Link))
return;
// update connector z-index of link
helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
});
});
}
connect(type, node, link) {
var linkRelations = link.relations();
var triple = helper.firstInstance(linkRelations, Triple);
var nodeKey = type + 'Node';
if (triple && triple[nodeKey]())
throw new Error('Already connected');
var anotherType = Cmap.anotherConnectionType(type);
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
if (anotherSideNode === node)
throw new Error('Already connected to the ' + anotherType + ' of the link');
if (triple) {
triple[nodeKey](node);
} else {
var tripleProps = {};
tripleProps.link = link;
tripleProps[nodeKey] = node;
triple = new Triple(tripleProps);
// add triple to the beginning of link relations to be ahead of link-connector relation
// connector position won't be updated before triple update
linkRelations.unshift(triple);
}
// add triple to node
node.relations().push(triple);
triple.updateNodePositionsCache();
// update connectors of link
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
if (relation.type() === type)
relation.isConnected(true);
});
// link content moves to midpoint of connected nodes
if (anotherSideNode) {
link.cx((node.cx() + anotherSideNode.cx()) / 2);
link.cy((node.cy() + anotherSideNode.cy()) / 2);
}
// do not need to mark node dirty (stay unchanged)
link.markDirty();
}
disconnect(type, node, link) {
if (type instanceof Component) {
var component = type;
var relations = component.relations().slice();
// disconnect all connections of component
helper.eachInstance(relations, Triple, function(triple) {
var link = triple.link();
var sourceNode = triple.sourceNode();
var targetNode = triple.targetNode();
if (sourceNode && (component === link || component === sourceNode))
this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link);
if (targetNode && (component === link || component === targetNode))
this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link);
}.bind(this));
return;
}
var linkRelations = link.relations();
var triple = helper.firstInstance(linkRelations, Triple);
var nodeKey = type + 'Node';
if (!triple || triple[nodeKey]() !== node)
throw new Error('Not connected');
triple[nodeKey](null);
// remove triple from node
var nodeRelations = node.relations();
nodeRelations.splice(nodeRelations.indexOf(triple), 1);
// remove triple from link
if (!triple.sourceNode() && !triple.targetNode())
linkRelations.splice(linkRelations.indexOf(triple), 1);
// update connectors of link
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
if (relation.type() === type)
relation.isConnected(false);
});
// do not need to mark node dirty (stay unchanged)
link.markDirty();
}
connectedNode(type, link) {
var triple = helper.firstInstance(link.relations(), Triple);
if (!triple)
return null;
return triple[type + 'Node']();
}
showConnector(type, link) {
if (this.connectorVisible(type, link))
return;
var disabledConnectorList = this.disabledConnectorList();
var connectorDisabled = disabledConnectorList.contains(type, link);
if (!connectorDisabled)
this.addConnector(type, link);
}
connectorVisible(type, link) {
return link.relations().some(function(relation) {
return relation instanceof LinkConnectorRelation && relation.type() === type;
});
}
addConnector(type, link) {
var connector = new Connector({
x: link[type + 'X'](),
y: link[type + 'Y']()
});
var linkConnectorRelation = new LinkConnectorRelation({
type: type,
link: link,
connector: connector
});
var linkRelations = link.relations();
var triple = helper.firstInstance(linkRelations, Triple);
var isConnected = (triple && !!triple[type + 'Node']());
linkConnectorRelation.isConnected(isConnected);
linkRelations.push(linkConnectorRelation);
connector.relations().push(linkConnectorRelation);
this.add(connector);
}
hideConnector(type, link) {
var linkRelations = link.relations();
for (var i = linkRelations.length - 1; i >= 0; i--) {
var relation = linkRelations[i];
if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type)
continue;
// remove connector component
this.remove(relation.connector());
// remove link-connector relation from link
linkRelations.splice(i, 1);
break;
}
}
showConnectors(link) {
this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link);
}
hideConnectors(link) {
this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link);
}
hideAllConnectors() {
this.componentList().toArray().forEach(function(component) {
if (component instanceof Link)
this.hideConnectors(component);
}.bind(this));
}
enableConnector(type, link) {
this.disabledConnectorList().remove(type, link);
}
disableConnector(type, link) {
// remove showing connector
this.hideConnector(type, link);
this.disabledConnectorList().add(type, link);
}
connectorEnabled(type, link) {
return !this.disabledConnectorList().contains(type, link);
}
enableDrag(component) {
this.dragDisabledComponentList().remove(component);
}
disableDrag(component) {
this.dragDisabledComponentList().add(component);
}
dragEnabled(component) {
return !this.dragDisabledComponentList().contains(component);
}
onstart(x, y, event) {
var context = this.dragContext();
var component = this.componentList().fromPoint(Component, x, y);
context.component = component;
if (typeof this.selectionHandler === 'function')
this.selectionHandler(component, event);
if (!(component instanceof Connector))
this.hideAllConnectors();
if (!component)
return;
var draggable = !this.dragDisabledComponentList().contains(component);
context.draggable = draggable;
if (!draggable)
return;
dom.cancel(event);
this.toFront(component);
if (component instanceof Node) {
context.x = component.x();
context.y = component.y();
} else if (component instanceof Link) {
context.cx = component.cx();
context.cy = component.cy();
context.sourceX = component.sourceX();
context.sourceY = component.sourceY();
context.targetX = component.targetX();
context.targetY = component.targetY();
context.triple = helper.firstInstance(component.relations(), Triple);
this.showConnectors(component);
} else if (component instanceof Connector) {
var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation);
context.x = x;
context.y = y;
context.type = linkConnectorRelation.type();
context.link = linkConnectorRelation.link();
}
this.fixScrollSize();
}
onmove(dx, dy, event) {
var context = this.dragContext();
var component = context.component;
if (!component)
return;
if (!context.draggable)
return;
if (component instanceof Node) {
var nodeX = context.x + dx;
var nodeY = context.y + dy;
if (typeof component.moveHandler === 'function') {
var constrainedPosition = component.moveHandler(nodeX, nodeY);
if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) {
nodeX = constrainedPosition.x;
nodeY = constrainedPosition.y;
}
}
component.x(nodeX);
component.y(nodeY);
} else if (component instanceof Link) {
var cx = context.cx + dx;
var cy = context.cy + dy;
var triple = context.triple;
var connectedNode = null;
if (triple) {
var sourceNode = triple.sourceNode();
var targetNode = triple.targetNode();
if (sourceNode && !targetNode)
connectedNode = sourceNode;
else if (!sourceNode && targetNode)
connectedNode = targetNode;
}
if (connectedNode) {
// only one node connected
var x = cx - connectedNode.cx();
var y = cy - connectedNode.cy();
triple.updateLinkAngle(Math.atan2(y, x));
triple.skipNextUpdate(true);
} else if (!triple || component.content()) {
// not connected or link has content
// (except two nodes connected but link has no content)
component.cx(cx);
component.cy(cy);
component.sourceX(context.sourceX + dx);
component.sourceY(context.sourceY + dy);
component.targetX(context.targetX + dx);
component.targetY(context.targetY + dy);
}
} else if (component instanceof Connector) {
var x = context.x + dx;
var y = context.y + dy;
var type = context.type;
var link = context.link;
var triple = helper.firstInstance(link.relations(), Triple);
var connectedNode = triple ? triple[type + 'Node']() : null;
var node = this.componentList().fromPoint(Node, x, y);
if (connectedNode && connectedNode === node) {
// already connected (do nothing)
return;
}
var anotherType = Cmap.anotherConnectionType(type);
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
if (connectedNode && connectedNode !== node) {
this.disconnect(type, connectedNode, link);
connectedNode = null;
}
var needsConnect = !connectedNode && node && anotherSideNode !== node;
if (needsConnect) {
if (anotherSideNode) {
var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy());
link[type + 'X'](p.x);
link[type + 'Y'](p.y);
triple.update(link);
triple.skipNextUpdate(true);
}
this.connect(type, node, link);
} else {
link[type + 'X'](x);
link[type + 'Y'](y);
if (!anotherSideNode)
link.straighten();
}
}
}
onend(dx, dy, event) {
var context = this.dragContext();
var component = context.component;
if (!component) {
this.lastClickComponent = null;
this.lastClickTime = 0;
return;
}
if (!context.draggable) {
this.lastClickComponent = null;
this.lastClickTime = 0;
return;
}
// dx/dy are logical map coordinates; keep the click tolerance at four
// physical screen pixels at every zoom level.
var clickTolerance = 4 / this.zoomFactor;
var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance;
if (isClick) {
var now = Date.now();
var isDoubleClick = (component === this.lastClickComponent &&
now - this.lastClickTime <= 500);
if (isDoubleClick) {
this.lastClickComponent = null;
this.lastClickTime = 0;
if (typeof this.activationHandler === 'function')
this.activationHandler(component, event);
} else {
this.lastClickComponent = component;
this.lastClickTime = now;
}
} else {
this.lastClickComponent = null;
this.lastClickTime = 0;
}
if (component instanceof Node && typeof component.moveEndHandler === 'function')
component.moveEndHandler(component.x(), component.y(), event);
if (component instanceof Connector) {
var link = context.link;
var triple = helper.firstInstance(link.relations(), Triple);
var connectedNode = triple ? triple[context.type + 'Node']() : null;
if (typeof link.connectionChangeHandler === 'function')
link.connectionChangeHandler(context.type, connectedNode, event);
}
this.unfixScrollSize();
}
fixScrollSize() {
var element = this.element();
var clientWidth = dom.clientWidth(element);
var clientHeight = dom.clientHeight(element);
var scrollWidth = dom.scrollWidth(element);
var scrollHeight = dom.scrollHeight(element);
// check if scrolled
if (clientWidth === scrollWidth && clientHeight === scrollHeight)
return;
var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)';
dom.css(this.retainerElement(), {
msTransform: translate,
transform: translate,
webkitTransform: translate
});
}
unfixScrollSize() {
var translate = 'translate(-1px, -1px)';
dom.css(this.retainerElement(), {
msTransform: translate,
transform: translate,
webkitTransform: translate
});
}
style() {
return {
color: '#333',
cursor: 'default',
fontFamily: 'sans-serif',
fontSize: '14px',
height: '100%',
MozUserSelect: 'none',
msUserSelect: 'none',
overflow: 'visible',
position: 'relative',
userSelect: 'none',
webkitUserSelect: 'none',
width: '100%',
zoom: this.zoomFactor
};
}
retainerStyle() {
return {
height: '1px',
pointerEvents: 'none',
position: 'absolute',
width: '1px'
};
}
redraw() {
if (this.disposed)
return;
var rootElement = this.rootElement();
if (!rootElement) {
rootElement = dom.body();
dom.css(rootElement, {
height: '100vh',
margin: '0',
width: '100vw'
});
this.rootElement(rootElement);
}
var previousElement = this.element();
var element = dom.el('<div>');
element.className = 'rw-cmap-surface';
dom.draggable(element, function(x, y, event) {
this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
}.bind(this), function(dx, dy, event) {
this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
}.bind(this), function(dx, dy, event) {
this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
}.bind(this));
this.element(element);
this.componentList().toArray().forEach(function(component) {
component.parentElement(element);
});
var retainerElement = dom.el('<div>');
dom.css(retainerElement, this.retainerStyle());
dom.append(element, retainerElement);
this.retainerElement(retainerElement);
// set initial position of retainer
this.unfixScrollSize();
dom.css(element, this.style());
if (previousElement && previousElement.parentNode)
previousElement.parentNode.removeChild(previousElement);
dom.append(rootElement, element);
}
}
Cmap.LINK_Z_INDEX_BASE = 100;
Cmap.NODE_Z_INDEX_BASE = 100000;
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
Cmap.CONNECTION_TYPE_SOURCE = 'source';
Cmap.CONNECTION_TYPE_TARGET = 'target';
export { Cmap as DrawingSurface };
@@ -0,0 +1,30 @@
import { CmapAppearance } from "./appearance.js";
/**
* Persist wiki-wide CMap appearance through the backend.
* Styles and the colour palette form one appearance aggregate.
*/
export class CmapAppearanceRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Load and deserialize the complete wiki-wide appearance aggregate. */
async load() {
const result = await this.api("/api/cmap-appearance");
return new CmapAppearance(result);
}
/** Persist one appearance aggregate and refresh it with backend normalization. */
async save(appearance) {
if (!(appearance instanceof CmapAppearance)) {
throw new TypeError("A CmapAppearance is required");
}
const result = await this.api("/api/cmap-appearance", {
method: "PUT",
body: JSON.stringify(appearance.toData())
});
return appearance.replace(result);
}
}
+181
View File
@@ -0,0 +1,181 @@
const STYLE_VALUE_KEYS = [
"backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle",
"synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "submapBackgroundColor", "submapBorderColor"
];
/** Return a detached value at the model boundary. */
function copy(value) {
return JSON.parse(JSON.stringify(value));
}
/** Return a six-digit HTML colour or the supplied fallback. */
function cmapColorValue(value, fallback = "#f3f6f8") {
return /^#[0-9a-f]{6}$/i.test(value || "") ? value.toLowerCase() : fallback;
}
/** Convert a stored CSS font size to typographic points. */
function cmapFontSizeInPoints(value, baseSize = 11) {
const size = Number.parseFloat(value);
if (!Number.isFinite(size)) return baseSize;
if (/px$/i.test(value || "")) return size * 0.75;
if (/em$/i.test(value || "")) return size * baseSize;
if (/%$/i.test(value || "")) return (size / 100) * baseSize;
return size;
}
/** Normalize an editable font size to the range accepted by the backend. */
function normalizedCmapFontSize(value, fallback) {
const size = Number(value);
const usableSize = Number.isFinite(size) ? size : fallback;
return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2));
}
/** Format a typographic point value for a form input. */
function displayCmapFontSize(value) {
return String(Math.round(value * 2) / 2);
}
/**
* Own the wiki-wide CMap styles and colour palette.
* The backend supplies the default style; this model validates UI changes
* against that style and exposes detached values to its consumers.
*/
export class CmapAppearance {
constructor(data) {
this.replace(data);
}
get styles() {
return copy(this._styles);
}
get palette() {
return [...this._palette];
}
get defaultValues() {
return copy(this._styles.find((style) => style.id === "default").values);
}
/** Replace the aggregate with a complete backend representation. */
replace(data) {
const styles = Array.isArray(data?.styles) ? data.styles : [];
const defaultStyle = styles.find((style) => style?.id === "default");
const palette = Array.isArray(data?.palette) ? data.palette : [];
const completeDefault = defaultStyle?.values &&
STYLE_VALUE_KEYS.every((key) => Object.hasOwn(defaultStyle.values, key));
if (!completeDefault || palette.length === 0) {
throw new TypeError("CMap appearance requires a default style and colour palette");
}
const seenIds = new Set();
this._styles = styles.map((style) => {
const id = typeof style?.id === "string" ? style.id.trim() : "";
const hasName = typeof style?.name === "string" && style.name.trim();
const hasNameKey = typeof style?.nameKey === "string" && style.nameKey.trim();
if (!/^[A-Za-z0-9_-]+$/.test(id) || seenIds.has(id) ||
(!hasName && !hasNameKey) || !style.values) {
throw new TypeError("CMap appearance contains an invalid style");
}
seenIds.add(id);
return {
id,
...(hasNameKey ? { nameKey: style.nameKey.trim() } : { name: style.name.trim() }),
protected: id === "default",
values: this.normalizeValues(style.values, defaultStyle.values)
};
});
this._palette = palette.map((color) => {
if (!/^#[0-9a-f]{6}$/i.test(color || "")) {
throw new TypeError("CMap appearance contains an invalid palette colour");
}
return color.toLowerCase();
});
return this;
}
/** Return the complete detached representation for backend storage. */
toData() {
return { styles: this.styles, palette: this.palette };
}
/** Normalize editable values using the backend-provided default style. */
normalizeValues(values, fallbackValues = null) {
if (!values || typeof values !== "object") return null;
const fallback = fallbackValues || this.defaultValues;
const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize);
return {
backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(values.textColor, fallback.textColor),
fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ?
values.fontFamily.trim() : fallback.fontFamily,
fontSize,
fontWeight: String(values.fontWeight) === "400" ? "400" : "700",
fontStyle: values.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor),
synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ?
values.synopsisFontFamily.trim() : fallback.synopsisFontFamily,
synopsisFontSize: normalizedCmapFontSize(
values.synopsisFontSize, Math.max(6, fontSize - 2)),
synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400",
synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal",
submapBackgroundColor: cmapColorValue(
values.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor)
};
}
style(id) {
const style = this._styles.find((candidate) => candidate.id === id);
return style ? copy(style) : null;
}
matchingStyleId(values) {
const normalized = this.normalizeValues(values);
if (!normalized) return "";
const style = this._styles.find((candidate) =>
STYLE_VALUE_KEYS.every((key) => candidate.values[key] === normalized[key]));
return style?.id || "";
}
/** Insert or replace one non-default named style. */
putStyle(style) {
const name = typeof style?.name === "string" ? style.name.trim() : "";
const id = typeof style?.id === "string" ? style.id.trim() : "";
if (!/^[A-Za-z0-9_-]+$/.test(id) || id === "default" || !name) {
throw new TypeError("A custom CMap style requires an id and name");
}
const normalized = {
id,
name,
protected: false,
values: this.normalizeValues(style.values)
};
const existingIndex = this._styles.findIndex((candidate) => candidate.id === normalized.id);
if (existingIndex < 0) this._styles.push(normalized);
else this._styles.splice(existingIndex, 1, normalized);
return copy(normalized);
}
/** Delete a custom style and refuse deletion of protected styles. */
deleteStyle(id) {
const style = this._styles.find((candidate) => candidate.id === id);
if (!style || style.protected) return false;
this._styles = this._styles.filter((candidate) => candidate.id !== id);
return true;
}
setPaletteColor(index, color) {
if (!Number.isInteger(index) || index < 0 || index >= this._palette.length ||
!/^#[0-9a-f]{6}$/i.test(color || "")) return false;
this._palette[index] = color.toLowerCase();
return true;
}
}
export {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize,
normalizedCmapFontSize
};
@@ -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) }));
}
}
+213
View File
@@ -0,0 +1,213 @@
import { CmapModel } from "./concept-map.js";
/** Return a detached copy of data received from or sent to the backend. */
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Decode the historical string-wrapped CMap document representation. */
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
throw new Error("The stored CMap document is not a JSON object.");
}
return copy(documentValue);
}
/** Give legacy map-local concept ids a stable identity before model creation. */
function normalizeConceptIdentities(documentValue, cmapSlug) {
const legacyIds = new Map();
const normalize = (conceptId) => {
const prefixedUuid = typeof conceptId === "string" && conceptId.match(
/^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (prefixedUuid) return prefixedUuid[1].toLowerCase();
if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId;
if (!legacyIds.has(conceptId)) {
legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`);
}
return legacyIds.get(conceptId);
};
for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
concept.id = normalize(concept.id);
}
for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) {
item.conceptId = normalize(item.conceptId);
}
return documentValue;
}
/** Convert a public CMap model to the canonical backend document. */
function modelDocument(value) {
if (!(value instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return value.toDocument();
}
/**
* Represent one persisted CMap together with its decoded domain model.
* Backend version fields remain available without exposing the response object.
*/
export class StoredConceptMap {
constructor(record, model) {
if (!record?.slug || !(model instanceof CmapModel)) {
throw new TypeError("A stored CMap requires a slug and CmapModel");
}
this._slug = String(record.slug);
this._title = String(record.title || record.slug);
this._currentVersion = Number(record.currentVersion || record.version) || 0;
this._version = Number(record.version || record.currentVersion) || 0;
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;
}
get slug() { return this._slug; }
get title() { return this._title; }
get currentVersion() { return this._currentVersion; }
get version() { return this._version; }
get createdAt() { return this._createdAt; }
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() {
return this._model.toDocument();
}
/** Return the metadata used by CMap selectors and workspace state. */
toSummary() {
return {
slug: this.slug,
title: this.title,
currentVersion: this.currentVersion,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
author: this.author
};
}
}
/**
* Store and retrieve concept maps through the Racket Wiki backend.
* This is the only CMap model class that knows API routes and storage envelopes.
*/
export class CmapRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Return the lightweight CMap records used by selectors. */
async list() {
const result = await this.api("/api/cmaps");
return Array.isArray(result.conceptMaps) ? result.conceptMaps.map(copy) : [];
}
/** Load one current CMap and deserialize its complete domain model. */
async load(slug) {
const record = await this.api(`/api/cmaps/${encodeURIComponent(slug)}`);
return this.storedMap(record, slug);
}
/** Create a persisted CMap from a domain 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",
body: JSON.stringify(body)
});
return this.storedMap(record, record.slug || slug);
}
/** Save a new model version and return the freshly versioned stored CMap. */
async save(storedMap, model, saveInformation = {}) {
if (!(storedMap instanceof StoredConceptMap)) {
throw new TypeError("Saving requires a StoredConceptMap");
}
const body = {
title: saveInformation.title || storedMap.title,
baseVersion: storedMap.currentVersion,
summary: saveInformation.summary || "",
saveKind: saveInformation.saveKind || "manual",
snapshot: Boolean(saveInformation.snapshot),
document: modelDocument(model),
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
};
const record = await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "PUT",
body: JSON.stringify(body)
});
return this.storedMap(record, storedMap.slug);
}
/** Rename a stored CMap using optimistic backend versioning. */
async rename(storedMap, title) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/rename`, {
method: "POST",
body: JSON.stringify({
title: String(title).trim(),
baseVersion: storedMap.currentVersion
})
});
return this.storedMap(record, storedMap.slug);
}
/** Archive a stored CMap after the workspace has obtained title confirmation. */
async archive(storedMap, confirmationTitle) {
await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "DELETE",
body: JSON.stringify({
confirmTitle: confirmationTitle,
baseVersion: storedMap.currentVersion
})
});
}
/** Return version summaries for one stored CMap. */
async history(storedMap) {
const result = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/history`);
return Array.isArray(result.versions) ? result.versions.map(copy) : [];
}
/** Load and deserialize one historical version of a CMap. */
async loadVersion(storedMap, version) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`);
return this.storedMap(record, storedMap.slug);
}
/** Delete one history version without changing the current CMap. */
async deleteVersion(storedMap, version) {
await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`,
{ method: "DELETE" });
}
/** Return the backend projection of concept placements across all CMaps. */
async conceptUsage() {
const result = await this.api("/api/cmaps/concept-usage");
return Array.isArray(result.placements) ? result.placements.map(copy) : [];
}
/** Convert one backend record to the public stored-map representation. */
storedMap(record, fallbackSlug = null) {
const slug = record?.slug || fallbackSlug;
const documentValue = decodedDocument(record?.document);
const normalized = normalizeConceptIdentities(documentValue, slug || "");
return new StoredConceptMap({ ...record, slug }, CmapModel.fromDocument(normalized));
}
}
+504
View File
@@ -0,0 +1,504 @@
import { ConceptRepository } from "./concept-repository.js";
const PLACEMENT_FIELDS = [
"parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth",
"expanded", "submapInitialized", "separateMap", "mapReference",
"hiddenContexts", "layouts", "backgroundColor", "borderColor",
"submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily",
"fontSize", "fontWeight", "fontStyle", "synopsisTextColor",
"synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Join one repository concept to its placement and appearance on a CMap. */
export class ConceptMapConcept {
constructor(id, conceptId, values = {}) {
if (!Number.isInteger(Number(id)) || !conceptId) {
throw new TypeError("A concept placement requires an integer id and concept id");
}
this.id = Number(id);
this.conceptId = String(conceptId);
this.kind = values.kind || "concept";
this.update(values);
}
update(values = {}) {
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentCmapLink = Boolean(this.parentCmapLink);
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.submapDepth = Number(this.submapDepth) || 0;
this.expanded = Boolean(this.expanded);
this.submapInitialized = Boolean(this.submapInitialized);
this.separateMap = Boolean(this.separateMap);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
this.autoWidth = Boolean(this.autoWidth);
this.autoHeight = Boolean(this.autoHeight);
return this;
}
toDocument() {
const document = { id: this.id, conceptId: this.conceptId, kind: this.kind };
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Represent a map-local linking phrase without creating a repository concept. */
export class ConceptMapPhrase {
constructor(id, values = {}) {
if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id");
this.id = Number(id);
this.kind = "phrase";
this.label = String(values.label || "?????");
this.synopsis = String(values.synopsis || "");
this.update(values);
}
update(values = {}) {
if (values.label !== undefined) this.label = String(values.label);
if (values.synopsis !== undefined) this.synopsis = String(values.synopsis);
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
return this;
}
toDocument() {
const document = {
id: this.id,
conceptId: null,
kind: "phrase",
label: this.label,
synopsis: this.synopsis
};
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Store one rendered connection between two map-local item placements. */
export class ConceptMapConnector {
constructor(id, sourceId, targetId, values = {}) {
if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) ||
!Number.isInteger(Number(targetId))) {
throw new TypeError("A connector requires integer ids");
}
this.id = Number(id);
this.sourceId = Number(sourceId);
this.targetId = Number(targetId);
this.relationId = values.relationId || null;
this.hasArrow = values.hasArrow !== false;
this.lineColor = values.lineColor || "#333";
this.lineWidth = Number(values.lineWidth) || 2;
}
toDocument() {
return {
id: this.id,
sourceId: this.sourceId,
targetId: this.targetId,
...(this.relationId ? { relationId: this.relationId } : {}),
hasArrow: this.hasArrow,
lineColor: this.lineColor,
lineWidth: this.lineWidth
};
}
}
/** Own the placements, phrases, connectors and metadata of one CMap. */
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 || "")
};
this.derivedView = values.derivedView ? copy(values.derivedView) : null;
this.itemsById = new Map();
this.connectorsById = new Map();
this.conceptMapsById = new Map();
}
addItem(value) {
const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value :
(value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) :
new ConceptMapConcept(value.id, value.conceptId, value));
if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`);
this.itemsById.set(item.id, item);
return item;
}
item(id) {
return this.itemsById.get(Number(id)) || null;
}
items() {
return [...this.itemsById.values()];
}
removeItem(id) {
const itemId = Number(id);
this.itemsById.delete(itemId);
for (const [connectorId, connector] of this.connectorsById) {
if (connector.sourceId === itemId || connector.targetId === itemId) {
this.connectorsById.delete(connectorId);
}
}
}
addConnector(value) {
const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector(
value.id, value.sourceId, value.targetId, value);
if (this.connectorsById.has(connector.id)) {
throw new Error(`Duplicate CMap connector id: ${connector.id}`);
}
this.connectorsById.set(connector.id, connector);
return connector;
}
connector(id) {
return this.connectorsById.get(Number(id)) || null;
}
connectors() {
return [...this.connectorsById.values()];
}
removeConnector(id) {
this.connectorsById.delete(Number(id));
}
setConceptMapReferences(references = []) {
this.conceptMapsById = new Map(references
.filter((reference) => reference && reference.id)
.map((reference) => [reference.id, copy(reference)]));
}
toDocument() {
const document = {
schemaVersion: this.schemaVersion,
metadata: copy(this.metadata),
items: this.items().map((item) => item.toDocument()),
connectors: this.connectors().map((connector) => connector.toDocument()),
conceptMaps: [...this.conceptMapsById.values()].map(copy)
};
if (this.derivedView) document.derivedView = copy(this.derivedView);
return document;
}
}
/** Combine a concept repository with one concrete concept-map document. */
export class CmapModel {
constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) {
this.repository = repository;
this.conceptMap = conceptMap;
}
static fromDocument(document = {}) {
const repository = new ConceptRepository(
Array.isArray(document.concepts) ? document.concepts : [],
Array.isArray(document.conceptRelations) ? document.conceptRelations : [],
Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []);
const conceptMap = new ConceptMap(document);
for (const item of (Array.isArray(document.items) ? document.items : [])) {
if (item.kind !== "phrase" && !repository.concept(item.conceptId)) {
repository.add({ id: item.conceptId, label: item.label || "Concept" });
}
conceptMap.addItem(item);
}
for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) {
conceptMap.addConnector(connector);
}
conceptMap.setConceptMapReferences(document.conceptMaps);
return new CmapModel(repository, conceptMap);
}
/** Return a detached copy of the map metadata. */
metadata() {
return copy(this.conceptMap.metadata);
}
/** Return a detached derived-view reference, or null for an independent map. */
derivedView() {
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();
document.metadata = copy(metadata);
return CmapModel.fromDocument(document);
}
/**
* Split an embedded submap into standalone child and remaining parent models.
* The owner stays in the parent as an ordinary concept and links to targetSlug.
* Descendant placements become the complete contents of the child map.
*/
extractSubmap(rootItemId, targetSlug = null, childMetadata = null) {
const sourceDocument = this.toDocument();
const rootId = Number(rootItemId);
const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId);
if (!rootItem || rootItem.kind !== "submap") {
throw new TypeError("A submap item is required for extraction");
}
const descendantIds = new Set();
let foundDescendant = true;
while (foundDescendant) {
foundDescendant = false;
for (const item of sourceDocument.items) {
const parentId = Number(item.parentSubmapId);
if (parentId !== rootId && !descendantIds.has(parentId)) continue;
if (descendantIds.has(Number(item.id))) continue;
descendantIds.add(Number(item.id));
foundDescendant = true;
}
}
const descendantItems = sourceDocument.items
.filter((item) => descendantIds.has(Number(item.id)));
if (!descendantItems.some((item) => item.kind !== "phrase")) {
throw new Error("The submap has no concepts to extract");
}
const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0));
const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0));
const childItems = descendantItems.map((item) => {
const x = (Number(item.x) || 0) - left + 80;
const y = (Number(item.y) || 0) - top + 80;
const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId;
const submapDepth = Math.max(
0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1);
return {
...item,
parentSubmapId,
submapDepth,
x,
y,
layouts: {
...(item.layouts || {}),
root: {
...(item.layouts?.root || {}),
x,
y,
width: Number(item.width),
height: Number(item.height)
}
}
};
});
const parentItems = sourceDocument.items
.filter((item) => !descendantIds.has(Number(item.id)))
.map((item) => Number(item.id) === rootId ? {
...item,
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null
} : item);
const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id)));
const parentConnectors = [];
const parentConnectorKeys = new Set();
const childConnectors = [];
for (const connector of sourceDocument.connectors) {
const sourceId = Number(connector.sourceId);
const targetId = Number(connector.targetId);
if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue;
const sourceInside = descendantIds.has(sourceId);
const targetInside = descendantIds.has(targetId);
if (sourceInside && targetInside) {
childConnectors.push({ ...connector });
continue;
}
if (sourceInside || targetInside) {
const rewired = {
...connector,
sourceId: sourceInside ? rootId : sourceId,
targetId: targetInside ? rootId : targetId
};
if (rewired.sourceId === rewired.targetId) continue;
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push(rewired);
}
continue;
}
const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push({ ...connector });
}
}
const conceptIdsFor = (items) => new Set(items
.filter((item) => item.kind !== "phrase" && item.conceptId)
.map((item) => String(item.conceptId)));
const childConceptIds = conceptIdsFor(childItems);
const parentConceptIds = conceptIdsFor(parentItems);
const ownerConceptId = String(rootItem.conceptId);
const conceptsFor = (ids) => sourceDocument.concepts
.filter((concept) => ids.has(String(concept.id)))
.map((concept) => ({ ...concept }));
const parentConcepts = conceptsFor(parentConceptIds);
if (targetSlug) {
const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId);
if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug);
}
const relationsFor = (ids) => (sourceDocument.conceptRelations || [])
.filter((relation) => ids.has(String(relation.sourceConceptId)) &&
ids.has(String(relation.targetConceptId)))
.map((relation) => ({ ...relation }));
const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || [])
.filter((ownership) => {
const parentId = String(ownership.parentConceptId);
const childId = String(ownership.childConceptId);
const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId);
return !extractedOwnership && ids.has(parentId) && ids.has(childId);
})
.map((ownership) => ({ ...ownership }));
const ownerConcept = sourceDocument.concepts
.find((concept) => String(concept.id) === ownerConceptId) || {};
const extractedMetadata = childMetadata ? copy(childMetadata) : {
tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [],
summary: String(ownerConcept.synopsis || ""),
explanationPageSlug: String(ownerConcept.descriptionPageSlug || "")
};
const childDocument = {
schemaVersion: sourceDocument.schemaVersion,
metadata: extractedMetadata,
concepts: conceptsFor(childConceptIds),
items: childItems,
connectors: childConnectors,
conceptMaps: sourceDocument.conceptMaps
.filter((reference) => descendantIds.has(Number(reference.rootItemId)))
};
const childRelations = relationsFor(childConceptIds);
const childOwnerships = ownershipsFor(childConceptIds);
if (childRelations.length) childDocument.conceptRelations = childRelations;
if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships;
const parentDocument = {
...sourceDocument,
concepts: parentConcepts,
items: parentItems,
connectors: parentConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
Number(reference.rootItemId) !== rootId &&
!descendantIds.has(Number(reference.rootItemId)))
};
const parentRelations = relationsFor(parentConceptIds);
const parentOwnerships = ownershipsFor(parentConceptIds);
if (parentRelations.length) parentDocument.conceptRelations = parentRelations;
else delete parentDocument.conceptRelations;
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
else delete parentDocument.conceptOwnerships;
return {
parentModel: CmapModel.fromDocument(parentDocument),
childModel: CmapModel.fromDocument(childDocument)
};
}
toDocument() {
const document = this.conceptMap.toDocument();
const usedConceptIds = new Set(this.conceptMap.items()
.filter((item) => item instanceof ConceptMapConcept)
.map((item) => item.conceptId));
document.concepts = this.repository.toDocument()
.filter((concept) => usedConceptIds.has(concept.id));
const relations = this.repository.relationDocuments();
const ownerships = this.repository.ownershipDocuments();
if (relations.length) document.conceptRelations = relations;
if (ownerships.length) document.conceptOwnerships = ownerships;
return document;
}
}
export { PLACEMENT_FIELDS };
+217
View File
@@ -0,0 +1,217 @@
const CONCEPT_FIELDS = [
"label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
const type = String(tag.type || "label").trim() || "label";
const value = String(tag.value || tag.name || "").trim();
return value ? { type, value } : null;
}).filter(Boolean);
}
/**
* Store the shared content and external references of one concept.
* A Concept never contains coordinates, styling or browser objects.
*/
export class Concept {
constructor(id, values = {}) {
if (!id) throw new TypeError("A concept id is required");
this.id = String(id);
this.label = String(values.label || "Concept");
this.synopsis = String(values.synopsis || "");
this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : [];
this.tags = normalizeTags(values.tags);
this.descriptionPageSlug = values.descriptionPageSlug || null;
this.pageSlug = values.pageSlug || null;
this.cmapSlug = values.cmapSlug || null;
this.externalUrl = values.externalUrl || null;
this.imageSource = values.imageSource || "";
}
/** Change shared content without accepting placement or presentation fields. */
update(changes = {}) {
for (const field of CONCEPT_FIELDS) {
if (changes[field] === undefined) continue;
if (field === "aspects") {
this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : [];
} else if (field === "tags") {
this.tags = normalizeTags(changes.tags);
} else {
this[field] = changes[field];
}
}
return this;
}
/** Return the stable JSON representation used at the wiki API boundary. */
toDocument() {
return {
id: this.id,
label: this.label,
synopsis: this.synopsis,
aspects: [...this.aspects],
tags: copy(this.tags),
descriptionPageSlug: this.descriptionPageSlug,
pageSlug: this.pageSlug,
cmapSlug: this.cmapSlug,
externalUrl: this.externalUrl,
imageSource: this.imageSource
};
}
}
/** Describe a semantic relation that exists independently of a diagram. */
export class ConceptRelation {
constructor(id, sourceConceptId, targetConceptId, values = {}) {
if (!id || !sourceConceptId || !targetConceptId) {
throw new TypeError("A concept relation requires an id and two concepts");
}
this.id = String(id);
this.sourceConceptId = String(sourceConceptId);
this.targetConceptId = String(targetConceptId);
this.label = String(values.label || "");
this.tags = normalizeTags(values.tags);
}
toDocument() {
return {
id: this.id,
sourceConceptId: this.sourceConceptId,
targetConceptId: this.targetConceptId,
label: this.label,
tags: copy(this.tags)
};
}
}
/** Describe semantic parent/child ownership between repository concepts. */
export class ConceptOwnership {
constructor(parentConceptId, childConceptId) {
if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) {
throw new TypeError("Concept ownership requires two different concepts");
}
this.parentConceptId = String(parentConceptId);
this.childConceptId = String(childConceptId);
}
toDocument() {
return {
parentConceptId: this.parentConceptId,
childConceptId: this.childConceptId
};
}
}
/**
* Own all shared concepts and their semantic relationships.
* Placements refer to this repository by concept id.
*/
export class ConceptRepository {
constructor(concepts = [], relations = [], ownerships = []) {
this.conceptsById = new Map();
this.relationsById = new Map();
this.ownershipsByChildId = new Map();
for (const value of concepts) this.add(value);
for (const value of relations) this.addRelation(value);
for (const value of ownerships) this.addOwnership(value);
}
add(value) {
const concept = value instanceof Concept ? value : new Concept(value.id, value);
this.conceptsById.set(concept.id, concept);
return concept;
}
ensure(id, values = {}) {
const key = String(id);
const existing = this.conceptsById.get(key);
if (existing) {
existing.update(values);
return existing;
}
return this.add(new Concept(key, values));
}
concept(id) {
return this.conceptsById.get(String(id)) || null;
}
concepts() {
return [...this.conceptsById.values()];
}
remove(id) {
const key = String(id);
this.conceptsById.delete(key);
for (const [relationId, relation] of this.relationsById) {
if (relation.sourceConceptId === key || relation.targetConceptId === key) {
this.relationsById.delete(relationId);
}
}
this.ownershipsByChildId.delete(key);
for (const [childId, ownership] of this.ownershipsByChildId) {
if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId);
}
}
addRelation(value) {
const relation = value instanceof ConceptRelation ? value : new ConceptRelation(
value.id, value.sourceConceptId, value.targetConceptId, value);
this.requireConcept(relation.sourceConceptId);
this.requireConcept(relation.targetConceptId);
this.relationsById.set(relation.id, relation);
return relation;
}
addOwnership(value) {
const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership(
value.parentConceptId, value.childConceptId);
this.requireConcept(ownership.parentConceptId);
this.requireConcept(ownership.childConceptId);
if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) {
throw new Error("Concept ownership would create a cycle");
}
this.ownershipsByChildId.set(ownership.childConceptId, ownership);
return ownership;
}
isOwnedBy(conceptId, possibleAncestorId) {
let current = this.ownershipsByChildId.get(String(conceptId));
const seen = new Set();
while (current && !seen.has(current.childConceptId)) {
if (current.parentConceptId === String(possibleAncestorId)) return true;
seen.add(current.childConceptId);
current = this.ownershipsByChildId.get(current.parentConceptId);
}
return false;
}
requireConcept(id) {
const concept = this.concept(id);
if (!concept) throw new Error(`Unknown concept: ${id}`);
return concept;
}
toDocument() {
return this.concepts().map((concept) => concept.toDocument());
}
relationDocuments() {
return [...this.relationsById.values()].map((relation) => relation.toDocument());
}
ownershipDocuments() {
return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument());
}
}
export { CONCEPT_FIELDS, normalizeTags };
+534
View File
@@ -0,0 +1,534 @@
/**
* Versioned JSON interchange for the Racket Wiki CMap model.
*
* The bundle separates shared concept content from map-local placement data.
* `buildBundle` follows map and page references, embeds the referenced page
* attachments, and validates the resulting object. Import code calls
* `validateBundle` first and then uses `preparedMapDocument` to join shared
* concepts back into a map document. The helpers in this module deliberately
* return detached JSON values so callers cannot mutate an input map or bundle.
*/
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
const SCHEMA = "/schemas/racket-wiki-cmap-bundle-v1.schema.json";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TEMPORARY_ID = /^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
const SLUG = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}$/u;
const CONCEPT_KEYS = [
"id", "label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
/** Return a detached JSON-compatible copy while preserving undefined. */
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/**
* Check a wiki page reference in either `slug` or `namespace:slug` form.
* The check is shared by concept links, map metadata and exported pages.
*/
function validPageReference(value) {
if (typeof value !== "string") return false;
const separator = value.indexOf(":");
if (separator < 0) return SLUG.test(value);
const namespace = value.slice(0, separator);
const slug = value.slice(separator + 1);
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
}
/** Accept only complete HTTP(S) URLs for external concept links. */
function validExternalUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === "http:" || url.protocol === "https:";
} catch (_error) {
return false;
}
}
/**
* Decode the one or two historical JSON string wrappers used by storage.
* Invalid or non-object values become an empty document; bundle validation
* remains responsible for rejecting malformed imported data.
*/
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
documentValue : {};
}
/** Copy only shared concept fields into the bundle-level concept table. */
function conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]);
}
return result;
}
/** Index valid document concepts by their stable identity. */
function conceptsById(documentValue) {
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
.map((concept) => [concept.id, concept]));
}
/** Return unique concept ids used by non-phrase placements. */
function itemConceptIds(documentValue) {
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
.map((item) => item.conceptId))];
}
/** Find linked CMap slugs in the concepts actually placed on a document. */
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
.map((id) => String(concepts.get(id)?.cmapSlug || "").trim())
.filter(Boolean))];
}
/**
* Collect page references from map metadata and placed concepts.
* Unplaced concept records are intentionally ignored because they are not
* part of the visible map dependency graph.
*/
function linkedPageReferences(documentValue) {
const references = new Set();
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
documentValue.metadata : {};
if (typeof metadata.explanationPageSlug === "string" && metadata.explanationPageSlug.trim()) {
references.add(metadata.explanationPageSlug.trim());
}
const concepts = conceptsById(documentValue);
for (const id of itemConceptIds(documentValue)) {
const concept = concepts.get(id) || {};
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) references.add(concept[key].trim());
}
}
return references;
}
/**
* Extract local upload URLs from Markdown and HTML-like markup.
* A Set removes duplicates; the final prefix check removes a shorter URL
* accidentally captured from a URL containing a space.
*/
function attachmentUrls(markdown) {
const source = String(markdown || "");
const urls = new Set();
const add = (value) => {
const url = String(value || "").trim();
if (url.startsWith("/uploads/") && url.split("/").length >= 4) urls.add(url);
};
for (const match of source.matchAll(/!?\[[^\]]*\]\((\/uploads\/[^)]*)\)/g)) add(match[1]);
for (const match of source.matchAll(/(?:src|href)\s*=\s*["'](\/uploads\/[^"']+)["']/gi)) add(match[1]);
for (const match of source.matchAll(/\/uploads\/[^\s"'<>\\)]+/g)) add(match[0]);
const collected = [...urls];
return collected.filter((url) => !collected.some((other) =>
other !== url && other.startsWith(`${url} `)));
}
/** Derive a readable fallback filename from an upload URL. */
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
return decodeURIComponent(encoded) || "attachment.bin";
} catch (_error) {
return encoded || "attachment.bin";
}
}
/** Replace upload URLs after imported attachments receive new server URLs. */
function replaceAttachmentUrls(markdown, replacements) {
let result = String(markdown || "");
const entries = replacements instanceof Map ? [...replacements.entries()] :
Object.entries(replacements || {});
entries.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of entries) {
if (!source || source === target) continue;
result = result.split(source).join(String(target));
}
return result;
}
/**
* Strip shared concept fields from placements while preserving layout data.
* It also keeps only connectors whose two local item endpoints still exist,
* because export must not emit dangling layout relations.
*/
function placementDocument(documentValue) {
const documentCopy = clone(decodedDocument(documentValue));
const ids = itemConceptIds(documentCopy);
for (const concept of (Array.isArray(documentCopy.concepts) ? documentCopy.concepts : [])) {
if (concept && typeof concept.id === "string" && !ids.includes(concept.id)) ids.push(concept.id);
}
documentCopy.items = (Array.isArray(documentCopy.items) ? documentCopy.items : []).map((item) => {
if (!item || item.kind === "phrase") return item;
return Object.fromEntries(Object.entries(item)
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
});
documentCopy.concepts = ids.map((id) => ({ id }));
const itemIds = new Set();
for (const item of documentCopy.items) {
const itemId = Number(item?.id);
if (Number.isInteger(itemId)) itemIds.add(itemId);
}
documentCopy.connectors = (Array.isArray(documentCopy.connectors) ?
documentCopy.connectors : []).filter((connector) => {
const sourceExists = itemIds.has(Number(connector?.sourceId));
const targetExists = itemIds.has(Number(connector?.targetId));
return sourceExists && targetExists;
});
return documentCopy;
}
/**
* Convert one wiki page and all uploads referenced by its Markdown into a
* bundle page record. The attachment loader is called once per unique URL.
*/
async function pageRecord(page, requestedReference, loadAttachment) {
const markdown = String(page.markdown || "");
const attachments = [];
for (const url of attachmentUrls(markdown)) {
if (typeof loadAttachment !== "function") {
throw new Error(`Attachment loader is required for ${url}.`);
}
const loaded = await loadAttachment(url, requestedReference);
if (!loaded || typeof loaded.contentBase64 !== "string") {
throw new Error(`Attachment ${url} did not provide base64 content.`);
}
attachments.push({
url,
name: String(loaded.name || attachmentName(url)),
mimeType: String(loaded.mimeType || "application/octet-stream"),
contentBase64: loaded.contentBase64
});
}
return {
reference: String(requestedReference),
title: String(page.title || page.slug || requestedReference),
markdown,
tags: Array.isArray(page.tags) ? page.tags.map(String) : [],
attachments
};
}
/**
* Build and validate a complete export bundle.
*
* @param {object} options Export options and asynchronous repository loaders.
* @param {object} options.rootMap Root map with `slug`, `title` and `document`.
* @param {Function} options.loadConceptMap Loads a linked map by slug.
* @param {Function} options.loadWikiPage Loads a linked wiki page by reference.
* @param {Function} [options.loadAttachment] Loads base64 upload content.
* @param {number} [options.maxDepth=0] Maximum depth for linked CMaps.
* @returns {Promise<object>} A validated, detached interchange bundle.
* @throws {Error} When required loaders are absent or a loaded record is invalid.
* @sideeffects Calls the supplied map, page and attachment loaders.
*
* `collectMap` traverses map dependencies and uses `placementDocument` for
* layout-only map records. It collects shared concepts and page references
* separately; page records are then built and the final aggregate is checked
* by `validateBundle` before it is returned.
*/
async function buildBundle(options) {
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
if (typeof options.loadWikiPage !== "function") throw new Error("loadWikiPage is required.");
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
const maps = [];
const concepts = new Map();
const pageReferences = new Set();
const visited = new Set();
const missingMaps = new Set();
async function collectMap(map, depth) {
if (!map?.slug || visited.has(map.slug)) return;
visited.add(map.slug);
const documentValue = decodedDocument(map.document);
maps.push({
slug: String(map.slug),
title: String(map.title || map.slug),
document: placementDocument(documentValue)
});
for (const concept of conceptsById(documentValue).values()) {
if (!concepts.has(concept.id)) concepts.set(concept.id, conceptContent(concept));
}
for (const reference of linkedPageReferences(documentValue)) pageReferences.add(reference);
const sourceSlug = String(documentValue.derivedView?.sourceCmapSlug || "").trim();
if (sourceSlug && !visited.has(sourceSlug)) {
try {
await collectMap(await options.loadConceptMap(sourceSlug), depth);
} catch (_error) {
missingMaps.add(sourceSlug);
}
}
if (depth >= maximumDepth) return;
for (const slug of linkedMapSlugs(documentValue)) {
if (visited.has(slug)) continue;
try {
await collectMap(await options.loadConceptMap(slug), depth + 1);
} catch (_error) {
missingMaps.add(slug);
}
}
}
await collectMap(options.rootMap, 0);
const pages = [];
const missingPages = [];
for (const reference of [...pageReferences].sort()) {
try {
pages.push(await pageRecord(
await options.loadWikiPage(reference), reference, options.loadAttachment));
} catch (_error) {
missingPages.push(reference);
}
}
const bundle = {
$schema: SCHEMA,
format: FORMAT,
formatVersion: FORMAT_VERSION,
exportedAt: options.exportedAt || new Date().toISOString(),
generator: options.generator || "Racket Wiki",
rootCmapSlug: String(options.rootMap.slug),
cmaps: maps,
concepts: [...concepts.values()],
pages,
missing: { cmaps: [...missingMaps].sort(), pages: missingPages }
};
validateBundle(bundle);
return bundle;
}
/**
* Validate the complete bundle contract and return the original bundle.
*
* @param {object} bundle Candidate bundle to validate.
* @returns {object} The same bundle object after successful validation.
* @throws {Error} With `validationErrors` when one or more contract checks fail.
*
* Validation also builds the concept, map, item and page-reference indexes
* needed to detect duplicate identities, dangling references and missing
* linked pages. Up to twenty errors are included in the message while the
* complete list remains available on `error.validationErrors`.
*/
function validateBundle(bundle) {
const errors = [];
const issue = (path, message) => errors.push(`${path}: ${message}`);
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new Error("The import must be a JSON object.");
}
if (bundle.format !== FORMAT) issue("format", `must be ${FORMAT}`);
if (bundle.formatVersion !== FORMAT_VERSION) issue("formatVersion", `must be ${FORMAT_VERSION}`);
if (!Array.isArray(bundle.cmaps) || !bundle.cmaps.length) issue("cmaps", "must contain at least one CMap");
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
// Validate the shared concept table and collect its page dependencies.
const conceptIds = new Set();
const conceptLabels = new Set();
const usedConceptIds = new Set();
const linkedPageReferences = new Set();
for (const [index, concept] of (Array.isArray(bundle.concepts) ? bundle.concepts : []).entries()) {
const path = `concepts[${index}]`;
if (!concept || typeof concept !== "object" || Array.isArray(concept)) {
issue(path, "must be an object");
continue;
}
if (typeof concept.id !== "string" || !(UUID.test(concept.id) || TEMPORARY_ID.test(concept.id))) {
issue(`${path}.id`, "must be a UUID or a new:<name> temporary id");
} else if (conceptIds.has(concept.id)) {
issue(`${path}.id`, "is duplicated");
} else conceptIds.add(concept.id);
if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required");
else {
const name = concept.label.trim().toLocaleLowerCase();
if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name");
else conceptLabels.add(name);
}
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) {
linkedPageReferences.add(concept[key].trim());
if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference");
}
}
if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) {
issue(`${path}.cmapSlug`, "must be a valid CMap slug");
}
if (concept.externalUrl !== undefined && concept.externalUrl !== null &&
(typeof concept.externalUrl !== "string" ||
!concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) {
issue(`${path}.externalUrl`, "must be a complete http or https URL");
}
}
// Validate map identities, placements, connectors and map-level page links.
const mapSlugs = new Set();
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
const path = `cmaps[${mapIndex}]`;
if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) {
issue(path, "must be an object");
continue;
}
if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug");
else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated");
else mapSlugs.add(cmap.slug);
if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required");
const documentValue = cmap.document;
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
issue(`${path}.document`, "must be an object");
continue;
}
const items = Array.isArray(documentValue.items) ? documentValue.items : [];
const metadataReference = documentValue.metadata?.explanationPageSlug;
if (typeof metadataReference === "string" && metadataReference.trim()) {
linkedPageReferences.add(metadataReference.trim());
if (!validPageReference(metadataReference.trim())) {
issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference");
}
}
const documentConceptIds = new Set();
for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ?
documentValue.concepts : []).entries()) {
const referencePath = `${path}.document.concepts[${referenceIndex}].id`;
if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) {
issue(referencePath, "must reference a concept in concepts[]");
} else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap");
else documentConceptIds.add(reference.id);
}
const itemIds = new Set();
for (const [itemIndex, item] of items.entries()) {
const itemPath = `${path}.document.items[${itemIndex}]`;
if (!item || typeof item !== "object" || Array.isArray(item)) {
issue(itemPath, "must be an object");
continue;
}
if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer");
else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap");
else itemIds.add(Number(item.id));
if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number");
if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number");
if (item.kind !== "phrase") {
if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) {
issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]");
} else usedConceptIds.add(item.conceptId);
}
}
for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ?
documentValue.connectors : []).entries()) {
const connectorPath = `${path}.document.connectors[${connectorIndex}]`;
if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item");
if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item");
}
}
if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) {
issue("rootCmapSlug", "must reference a CMap in cmaps[]");
}
for (const id of conceptIds) {
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
}
// Validate page records and their embedded attachment payloads.
const pageReferences = new Set();
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
const path = `pages[${index}]`;
if (!page || typeof page !== "object" || Array.isArray(page)) {
issue(path, "must be an object");
continue;
}
if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference");
else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated");
else pageReferences.add(page.reference);
if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required");
if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string");
if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) {
issue(`${path}.tags`, "must be an array of strings");
}
if (page.attachments !== undefined && !Array.isArray(page.attachments)) {
issue(`${path}.attachments`, "must be an array");
}
const attachmentReferences = new Set();
for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ?
page.attachments : []).entries()) {
const attachmentPath = `${path}.attachments[${attachmentIndex}]`;
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
issue(attachmentPath, "must be an object");
continue;
}
if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) {
issue(`${attachmentPath}.url`, "must be a local /uploads/ URL");
} else if (attachmentReferences.has(attachment.url)) {
issue(`${attachmentPath}.url`, "is duplicated within the page");
} else attachmentReferences.add(attachment.url);
if (typeof attachment.name !== "string" || !attachment.name.trim()) {
issue(`${attachmentPath}.name`, "is required");
}
if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) {
issue(`${attachmentPath}.mimeType`, "is required");
}
if (typeof attachment.contentBase64 !== "string" ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) {
issue(`${attachmentPath}.contentBase64`, "must be valid base64");
}
}
}
// Reconcile all collected page dependencies with present or declared-missing pages.
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
for (const reference of linkedPageReferences) {
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation");
}
}
// Report all discovered issues together so import callers can repair a bundle in one pass.
if (errors.length) {
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
error.validationErrors = errors;
throw error;
}
return bundle;
}
/**
* Rejoin bundle-level concept content with one placement-only map document.
*
* @param {object} bundle A bundle that satisfies `validateBundle`.
* @param {object} cmap One entry from `bundle.cmaps`.
* @returns {object} A detached document suitable for repository import.
* @throws {Error} When the bundle is invalid or a referenced concept is absent.
*/
function preparedMapDocument(bundle, cmap) {
validateBundle(bundle);
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
const documentValue = clone(cmap.document);
const ids = itemConceptIds(documentValue);
for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id);
}
documentValue.concepts = ids.map((id) => clone(byId.get(id)));
return documentValue;
}
export {
FORMAT,
FORMAT_VERSION,
SCHEMA,
attachmentUrls,
buildBundle,
decodedDocument,
placementDocument,
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
};
+69
View File
@@ -0,0 +1,69 @@
import { buildBundle } from "./interchange.js";
/** Convert an ArrayBuffer to the base64 representation used in JSON bundles. */
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
/**
* Export a stored CMap model and its linked resources as a complete JSON bundle.
* CMaps are loaded through their repository. Page loading, bundle construction
* and attachment encoding remain independent of workspace presentation.
*/
export class CmapJsonExporter {
constructor(cmapRepository, loadWikiPage, fetchFile, generator = "Racket Wiki") {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof loadWikiPage !== "function" || typeof fetchFile !== "function") {
throw new TypeError("A CMap repository, wiki-page loader and file loader are required");
}
this.cmapRepository = cmapRepository;
this.loadWikiPage = loadWikiPage;
this.fetchFile = fetchFile;
this.generator = generator;
}
/**
* Build a validated JSON bundle without changing the source CMaps.
* Linked CMaps are followed up to maxDepth; linked pages and attachments
* are included by the interchange format.
*/
async export(rootMap, maxDepth = 0) {
return buildBundle({
rootMap: this.bundleMap(rootMap),
maxDepth,
generator: this.generator,
loadConceptMap: async (slug) => this.bundleMap(await this.cmapRepository.load(slug)),
loadWikiPage: this.loadWikiPage,
loadAttachment: (url) => this.loadAttachment(url)
});
}
/** Present one stored model through the public interchange record shape. */
bundleMap(storedMap) {
if (!storedMap?.slug || typeof storedMap.toDocument !== "function") {
throw new TypeError("JSON export requires a stored CMap");
}
return {
slug: storedMap.slug,
title: storedMap.title,
document: storedMap.toDocument()
};
}
/** Load and encode one attachment referenced by an exported wiki page. */
async loadAttachment(url) {
const response = await this.fetchFile(url);
if (!response.ok) throw new Error(`Attachment could not be exported: ${url}`);
const content = await response.arrayBuffer();
return {
mimeType: response.headers.get("content-type") || "application/octet-stream",
contentBase64: arrayBufferToBase64(content)
};
}
}
+166
View File
@@ -0,0 +1,166 @@
import {
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
} from "./interchange.js";
import { CmapModel } from "./concept-map.js";
const MAXIMUM_FILE_SIZE = 256 * 1024 * 1024;
const MAXIMUM_ATTACHMENT_SIZE = 50 * 1024 * 1024;
/** Decode one bundle attachment as a Blob accepted by the upload API. */
function attachmentBlob(attachment) {
const binary = atob(String(attachment.contentBase64 || ""));
if (binary.length > MAXIMUM_ATTACHMENT_SIZE) {
throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`);
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], {
type: attachment.mimeType || "application/octet-stream"
});
}
/**
* Import a validated JSON bundle through the CMap repository and page API.
* Conflict policy is supplied by the workspace; this class performs only the
* deterministic page, attachment and CMap writes and reports their counts.
*/
export class CmapJsonImporter {
constructor(cmapRepository, pageApi, translate) {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof pageApi !== "function" || typeof translate !== "function") {
throw new TypeError("A CMap repository, page API and translator are required");
}
this.cmapRepository = cmapRepository;
this.pageApi = pageApi;
this.translate = translate;
}
/** Parse and validate a selected JSON file without writing wiki data. */
async read(file) {
if (file.size > MAXIMUM_FILE_SIZE) {
throw new Error(this.translate(
"cmap-json-too-large", "The CMap JSON file exceeds 256 MiB."));
}
const bundle = JSON.parse(await file.text());
return validateBundle(bundle);
}
/** Return the pages and CMaps whose slugs already occur in the wiki. */
conflicts(bundle, pages, conceptMaps) {
const existingPages = new Map(pages.map((page) => [page.slug, page]));
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
return {
pages: bundle.pages.filter((page) => existingPages.has(page.reference)),
conceptMaps: bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug))
};
}
/**
* Write all accepted bundle records and return created, updated and skipped counts.
* Existing records are replaced only when replaceExisting is true.
*/
async import(bundle, options = {}) {
validateBundle(bundle);
const pages = Array.isArray(options.pages) ? options.pages : [];
const conceptMaps = Array.isArray(options.conceptMaps) ? options.conceptMaps : [];
const replaceExisting = Boolean(options.replaceExisting);
const summary = String(options.summary || "Imported from CMap JSON");
const existingPages = new Map(pages.map((page) => [page.slug, page]));
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
const result = {
mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0,
pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0,
attachmentsImported: 0
};
for (const page of bundle.pages) {
const existing = existingPages.get(page.reference);
if (existing && !replaceExisting) {
result.pagesSkipped += 1;
continue;
}
await this.importPage(page, existing, summary);
if (existing) result.pagesUpdated += 1;
else result.pagesCreated += 1;
result.attachmentsImported += Array.isArray(page.attachments) ?
page.attachments.length : 0;
}
for (const cmap of bundle.cmaps) {
const existing = existingMaps.get(cmap.slug);
if (existing && !replaceExisting) {
result.mapsSkipped += 1;
continue;
}
await this.importConceptMap(bundle, cmap, existing, summary);
if (existing) result.mapsUpdated += 1;
else result.mapsCreated += 1;
}
return result;
}
/** Store one page and upload the attachments embedded in its Markdown. */
async importPage(page, existing, summary) {
const attachments = Array.isArray(page.attachments) ? page.attachments : [];
const body = {
slug: page.reference,
title: page.title,
markdown: page.markdown,
tags: page.tags,
summary
};
if (existing) {
body.markdown = await this.importAttachments(page);
body.baseVersion = existing.currentVersion;
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
return;
}
const created = await this.pageApi("/api/pages", {
method: "POST", body: JSON.stringify(body)
});
if (!attachments.length) return;
body.markdown = await this.importAttachments(page);
body.baseVersion = created.currentVersion;
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
}
/** Upload one page's attachments and rewrite their Markdown URLs. */
async importAttachments(page) {
const replacements = new Map();
for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) {
const uploaded = await this.pageApi(
`/api/pages/${encodeURIComponent(page.reference)}/upload`, {
method: "POST",
headers: { "X-File-Name": attachment.name },
body: attachmentBlob(attachment)
});
replacements.set(attachment.url, uploaded.url);
}
return replaceAttachmentUrls(page.markdown, replacements);
}
/** Store one CMap after rejoining shared concepts and placements. */
async importConceptMap(bundle, cmap, existing, summary) {
const documentValue = preparedMapDocument(bundle, cmap);
const model = CmapModel.fromDocument(documentValue);
if (existing) {
const storedMap = await this.cmapRepository.load(cmap.slug);
await this.cmapRepository.save(storedMap, model, {
title: cmap.title,
saveKind: "manual",
summary
});
return;
}
await this.cmapRepository.create(cmap.title, model, cmap.slug);
}
}
+364
View File
@@ -0,0 +1,364 @@
/* Build a self-contained Markdown report from one stored CMap model and its links. */
const labels = {
en: {
exportTitle: "CMap export", generated: "Generated", root: "Root CMap",
depth: "Linked CMap depth", pagesIncluded: "Linked wiki pages included",
yes: "yes", no: "no", cmap: "CMap", level: "level", address: "Address",
tags: "Tags", none: "none", summary: "Summary", noSummary: "No summary supplied.",
explanation: "CMap explanation", concepts: "Concepts", relations: "Relations",
linkedMaps: "Linked CMaps", noConcepts: "No concepts.", noRelations: "No relations.",
synopsis: "Summary", aspects: "Aspects", wikiPage: "Wiki page",
conceptExplanation: "Concept explanation", linkedCmap: "Linked CMap", webPage: "Web page",
people: "People (responsibility/action)",
placements: "Placements", sourceView: "Derived view of", sourceMissing: "Source CMap unavailable",
missingPage: "Page unavailable", linkedPages: "Linked wiki pages", page: "Page"
},
nl: {
exportTitle: "CMap-export", generated: "Gegenereerd", root: "Start-CMap",
depth: "Diepte gekoppelde CMaps", pagesIncluded: "Gekoppelde wikipagina's opgenomen",
yes: "ja", no: "nee", cmap: "CMap", level: "niveau", address: "Adres",
tags: "Tags", none: "geen", summary: "Samenvatting", noSummary: "Geen samenvatting opgegeven.",
explanation: "CMap-uitleg", concepts: "Concepten", relations: "Relaties",
linkedMaps: "Gekoppelde CMaps", noConcepts: "Geen concepten.", noRelations: "Geen relaties.",
synopsis: "Samenvatting", aspects: "Aspecten", wikiPage: "Wikipagina",
conceptExplanation: "Conceptuitleg", linkedCmap: "Gekoppelde CMap", webPage: "Webpagina",
people: "Personen (verantwoordelijkheid/actie)",
placements: "Plaatsingen", sourceView: "Afgeleide weergave van", sourceMissing: "Bron-CMap niet beschikbaar",
missingPage: "Pagina niet beschikbaar", linkedPages: "Gekoppelde wikipagina's", page: "Pagina"
}
};
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
documentValue : {};
}
function cleanMetadata(documentValue) {
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
documentValue.metadata : {};
return {
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()
};
}
function itemRecords(documentValue) {
const concepts = new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && concept.id)
.map((concept) => [String(concept.id), concept]));
return (Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.id !== undefined)
.map((item) => ({
...item,
...(concepts.get(String(item.conceptId)) || {}),
id: item.id,
conceptId: item.conceptId
}));
}
function derivedDocument(sourceDocument, derivedDocumentValue) {
const pointer = derivedDocumentValue.derivedView || {};
const rootId = Number(pointer.rootItemId);
const allItems = Array.isArray(sourceDocument.items) ? sourceDocument.items : [];
const byId = new Map(allItems.map((item) => [Number(item.id), item]));
const belongsToRoot = (item) => {
if (Number(item.id) === rootId) return true;
let parentId = Number(item.parentSubmapId);
const seen = new Set();
while (Number.isInteger(parentId) && parentId > 0 && !seen.has(parentId)) {
if (parentId === rootId) return true;
seen.add(parentId);
parentId = Number(byId.get(parentId)?.parentSubmapId);
}
return false;
};
const items = allItems.filter(belongsToRoot);
const itemIds = new Set(items.map((item) => Number(item.id)));
const conceptIds = new Set(items.map((item) => item.conceptId).filter(Boolean).map(String));
const rootItem = itemRecords(sourceDocument).find((item) => Number(item.id) === rootId) ||
byId.get(rootId) || {};
const ownMetadata = cleanMetadata(derivedDocumentValue);
const metadata = {
tags: ownMetadata.tags.length ? ownMetadata.tags :
(Array.isArray(rootItem.aspects) ? rootItem.aspects.map(String) : []),
summary: ownMetadata.summary || String(rootItem.synopsis || "").trim(),
explanationPageSlug: ownMetadata.explanationPageSlug || String(rootItem.descriptionPageSlug || "").trim()
};
return {
...sourceDocument,
metadata,
items,
concepts: (Array.isArray(sourceDocument.concepts) ? sourceDocument.concepts : [])
.filter((concept) => conceptIds.has(String(concept.id))),
connectors: (Array.isArray(sourceDocument.connectors) ? sourceDocument.connectors : [])
.filter((connector) => itemIds.has(Number(connector.sourceId)) && itemIds.has(Number(connector.targetId)))
};
}
async function resolveMapDocument(map, loadConceptMap) {
const ownDocument = decodedDocument(map.document);
const pointer = ownDocument.derivedView;
if (!pointer || !pointer.sourceCmapSlug || !Number.isInteger(Number(pointer.rootItemId))) {
return { document: ownDocument, sourceSlug: "" };
}
try {
const sourceMap = await loadConceptMap(pointer.sourceCmapSlug);
return {
document: derivedDocument(decodedDocument(sourceMap.document), ownDocument),
sourceSlug: pointer.sourceCmapSlug
};
} catch (_error) {
return { document: ownDocument, sourceSlug: pointer.sourceCmapSlug, sourceMissing: true };
}
}
function linkedMapSlugs(documentValue) {
return [...new Set(itemRecords(documentValue)
.map((item) => String(item.cmapSlug || "").trim())
.filter(Boolean))];
}
function headingText(value) {
return String(value || "").replace(/[\r\n]+/g, " ").replace(/#+/g, "").trim();
}
function inlineText(value) {
return String(value || "").replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]])/g, "\\$1").trim();
}
function shiftHeadings(markdown, amount) {
let fenced = false;
return String(markdown || "").split("\n").map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
fenced = !fenced;
return line;
}
if (fenced) return line;
return line.replace(/^(#{1,6})\s+/, (match, hashes) =>
`${"#".repeat(Math.min(6, hashes.length + amount))} `);
}).join("\n");
}
function relationLines(documentValue) {
const items = itemRecords(documentValue);
const byId = new Map(items.map((item) => [Number(item.id), item]));
const connectors = (Array.isArray(documentValue.connectors) ? documentValue.connectors : [])
.filter((connector) => connector && connector.sourceId !== undefined && connector.targetId !== undefined);
const used = new Set();
const result = [];
const itemLabel = (id) => headingText(byId.get(Number(id))?.label || `[${id}]`);
for (const phrase of items.filter((item) => item.kind === "phrase")) {
const incoming = connectors.filter((connector) => Number(connector.targetId) === Number(phrase.id));
const outgoing = connectors.filter((connector) => Number(connector.sourceId) === Number(phrase.id));
for (const before of incoming) {
for (const after of outgoing) {
used.add(before);
used.add(after);
result.push(`${itemLabel(before.sourceId)} — **${inlineText(phrase.label || "")}** → ${itemLabel(after.targetId)}`);
}
}
}
for (const connector of connectors) {
if (used.has(connector)) continue;
result.push(`${itemLabel(connector.sourceId)} ${connector.hasArrow === false ? "—" : "→"} ${itemLabel(connector.targetId)}`);
}
return result;
}
function conceptEntries(documentValue) {
const entries = new Map();
for (const item of itemRecords(documentValue)) {
if (item.kind === "phrase") continue;
const key = item.conceptId ? `concept:${item.conceptId}` : `item:${item.id}`;
if (!entries.has(key)) entries.set(key, { ...item, placementCount: 0 });
entries.get(key).placementCount += 1;
}
return [...entries.values()];
}
async function generateMarkdown(options) {
const rootMap = options.rootMap;
if (!rootMap || !rootMap.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
const includeWikiPages = Boolean(options.includeWikiPages);
const locale = String(options.language || "nl").toLowerCase().startsWith("nl") ? "nl" : "en";
const t = labels[locale];
const maps = [];
const visited = new Set();
async function collectMap(map, depth) {
if (!map?.slug || visited.has(map.slug)) return;
visited.add(map.slug);
const resolved = await resolveMapDocument(map, options.loadConceptMap);
maps.push({ map, depth, ...resolved });
if (depth >= maximumDepth) return;
for (const slug of linkedMapSlugs(resolved.document)) {
if (visited.has(slug)) continue;
try {
await collectMap(await options.loadConceptMap(slug), depth + 1);
} catch (error) {
maps.push({ map: { slug, title: slug }, depth: depth + 1, document: {}, loadError: error });
visited.add(slug);
}
}
}
await collectMap(rootMap, 0);
const pageCache = new Map();
const explanationReferences = new Set();
async function loadPage(reference) {
if (!reference || typeof options.loadWikiPage !== "function") return null;
if (!pageCache.has(reference)) {
pageCache.set(reference, Promise.resolve().then(() => options.loadWikiPage(reference))
.catch((error) => ({ slug: reference, title: reference, loadError: error })));
}
return pageCache.get(reference);
}
for (const entry of maps) {
const metadata = cleanMetadata(entry.document);
if (metadata.explanationPageSlug) {
explanationReferences.add(metadata.explanationPageSlug);
await loadPage(metadata.explanationPageSlug);
}
if (includeWikiPages) {
for (const concept of conceptEntries(entry.document)) {
await loadPage(concept.pageSlug);
await loadPage(concept.descriptionPageSlug);
}
}
}
const lines = [
`# ${t.exportTitle}: ${headingText(rootMap.title || rootMap.slug)}`,
"",
`- **${t.generated}:** ${new Date().toISOString()}`,
`- **${t.root}:** \`cmap:${rootMap.slug}\``,
`- **${t.depth}:** ${maximumDepth}`,
`- **${t.pagesIncluded}:** ${includeWikiPages ? t.yes : t.no}`,
""
];
for (const entry of maps) {
const metadata = cleanMetadata(entry.document);
lines.push(`## ${t.cmap}: ${headingText(entry.map.title || entry.map.slug)} (${t.level} ${entry.depth})`, "");
lines.push(`- **${t.address}:** \`cmap:${entry.map.slug}\``);
lines.push(`- **${t.tags}:** ${metadata.tags.length ? metadata.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ") : t.none}`);
if (entry.sourceSlug) lines.push(`- **${t.sourceView}:** \`cmap:${entry.sourceSlug}\``);
if (entry.sourceMissing) lines.push(`- **${t.sourceMissing}:** \`cmap:${entry.sourceSlug}\``);
if (entry.loadError) lines.push(`- **Fout:** ${inlineText(entry.loadError.message || entry.loadError)}`);
lines.push("", `### ${t.summary}`, "", metadata.summary || t.noSummary, "");
if (metadata.explanationPageSlug) {
const page = await loadPage(metadata.explanationPageSlug);
lines.push(`### ${t.explanation}`, "", `**${t.page}:** \`${metadata.explanationPageSlug}\``, "");
if (page?.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
else if (page) {
if (Array.isArray(page.tags) && page.tags.length) {
lines.push(`**${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`, "");
}
lines.push(shiftHeadings(page.markdown || "", 3).trim() || t.noSummary, "");
}
}
lines.push(`### ${t.concepts}`, "");
const concepts = conceptEntries(entry.document);
if (!concepts.length) lines.push(`_${t.noConcepts}_`, "");
for (const concept of concepts) {
lines.push(`#### ${headingText(concept.label || concept.id || "Concept")}`, "");
if (concept.synopsis) lines.push(`- **${t.synopsis}:** ${inlineText(concept.synopsis)}`);
if (Array.isArray(concept.aspects) && concept.aspects.length) {
lines.push(`- **${t.aspects}:** ${concept.aspects.map(inlineText).join(", ")}`);
}
const people = (Array.isArray(concept.tags) ? concept.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => tag.value);
if (people.length) {
lines.push(`- **${t.people}:** ${people.map(inlineText).join(", ")}`);
}
if (concept.pageSlug) lines.push(`- **${t.wikiPage}:** \`${inlineText(concept.pageSlug)}\``);
if (concept.descriptionPageSlug) lines.push(`- **${t.conceptExplanation}:** \`${inlineText(concept.descriptionPageSlug)}\``);
if (concept.cmapSlug) lines.push(`- **${t.linkedCmap}:** \`cmap:${inlineText(concept.cmapSlug)}\``);
if (concept.externalUrl) lines.push(`- **${t.webPage}:** ${inlineText(concept.externalUrl)}`);
if (concept.placementCount > 1) lines.push(`- **${t.placements}:** ${concept.placementCount}`);
lines.push("");
}
lines.push(`### ${t.relations}`, "");
const relations = relationLines(entry.document);
if (!relations.length) lines.push(`_${t.noRelations}_`, "");
else lines.push(...relations.map((relation) => `- ${relation}`), "");
const linked = linkedMapSlugs(entry.document);
if (linked.length) {
lines.push(`### ${t.linkedMaps}`, "", ...linked.map((slug) => `- \`cmap:${inlineText(slug)}\``), "");
}
}
if (includeWikiPages) {
const pages = [];
for (const [reference, promise] of pageCache) {
if (explanationReferences.has(reference)) continue;
pages.push(await promise);
}
if (pages.length) lines.push(`## ${t.linkedPages}`, "");
for (const page of pages) {
lines.push(`### ${headingText(page.title || page.slug)}`, "", `- **${t.address}:** \`${inlineText(page.slug)}\``);
if (Array.isArray(page.tags) && page.tags.length) {
lines.push(`- **${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`);
}
lines.push("");
if (page.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
else lines.push(shiftHeadings(page.markdown || "", 2).trim() || t.noSummary, "");
}
}
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
}
/**
* Export a CMap and optionally its linked maps and wiki pages as Markdown.
* The exporter reads stored models through their repository and has no
* knowledge of dialogs, downloads or other browser presentation.
*/
export class CmapMarkdownExporter {
constructor(cmapRepository, loadWikiPage) {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof loadWikiPage !== "function") {
throw new TypeError("A CMap repository and wiki-page loader are required");
}
this.cmapRepository = cmapRepository;
this.loadWikiPage = loadWikiPage;
}
/** Build the complete Markdown report without changing source data. */
async export(rootMap, options = {}) {
return generateMarkdown({
rootMap: this.exportMap(rootMap),
maxDepth: options.maxDepth,
includeWikiPages: options.includeWikiPages,
language: options.language,
loadConceptMap: async (slug) => this.exportMap(await this.cmapRepository.load(slug)),
loadWikiPage: this.loadWikiPage
});
}
/** Present one stored model through the record shape consumed by the report builder. */
exportMap(storedMap) {
if (!storedMap?.slug || typeof storedMap.toDocument !== "function") {
throw new TypeError("Markdown export requires a stored CMap");
}
return {
slug: storedMap.slug,
title: storedMap.title,
document: storedMap.toDocument()
};
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+25
View File
@@ -0,0 +1,25 @@
/** Persist the people referenced by CMap concept tags through the wiki API. */
export class PeopleRepository {
constructor(api) {
this.api = api;
}
async all() {
const result = await this.api("/api/people");
return Array.isArray(result.people) ? result.people : [];
}
create(name) {
return this.api("/api/people", {
method: "POST",
body: JSON.stringify({ name })
});
}
update(person, active) {
return this.api(`/api/people/${person.id}`, {
method: "PUT",
body: JSON.stringify({ name: person.name, active })
});
}
}
@@ -0,0 +1,67 @@
/** Keep one context address unambiguous inside the in-memory lookup table. */
function zoomKey(cmapSlug, contextKey) {
return `${cmapSlug}\u0000${contextKey}`;
}
/**
* Represent wiki and user settings for the CMap workspace.
* The backend is authoritative; this object only caches the active session state.
*/
export class CmapSettingsRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
this.loaded = false;
this.startCmapSlug = "";
this.pageGuidesVisible = true;
this.zoomLevels = new Map();
}
async load() {
const result = await this.api("/api/cmap-settings");
this.startCmapSlug = typeof result.startCmapSlug === "string" ? result.startCmapSlug : "";
this.pageGuidesVisible = result.pageGuidesVisible !== false;
this.zoomLevels.clear();
for (const entry of (Array.isArray(result.zooms) ? result.zooms : [])) {
const zoom = Number(entry.zoomPercent);
if (entry.cmapSlug && entry.contextKey && zoom >= 25 && zoom <= 300) {
this.zoomLevels.set(zoomKey(entry.cmapSlug, entry.contextKey), zoom);
}
}
this.loaded = true;
return this;
}
zoom(cmapSlug, contextKey) {
return this.zoomLevels.get(zoomKey(cmapSlug, contextKey)) || 100;
}
async setStartCmap(slug) {
const result = await this.api("/api/cmap-settings/start", {
method: "PUT",
body: JSON.stringify({ startCmapSlug: slug || "" })
});
this.startCmapSlug = result.startCmapSlug || "";
return this.startCmapSlug;
}
async setPageGuidesVisible(visible) {
const result = await this.api("/api/cmap-settings/page-guides", {
method: "PUT",
body: JSON.stringify({ pageGuidesVisible: Boolean(visible) })
});
this.pageGuidesVisible = result.pageGuidesVisible !== false;
return this.pageGuidesVisible;
}
async setZoom(cmapSlug, contextKey, zoomPercent) {
if (!cmapSlug) return zoomPercent;
const result = await this.api("/api/cmap-settings/zoom", {
method: "PUT",
body: JSON.stringify({ cmapSlug, contextKey, zoomPercent })
});
const storedZoom = Number(result.zoomPercent);
this.zoomLevels.set(zoomKey(cmapSlug, contextKey), storedZoom);
return storedZoom;
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+398
View File
@@ -0,0 +1,398 @@
import {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize
} from "../model/appearance.js";
/**
* Present and edit CMap appearance in the concept dialog.
* The editor translates DOM changes to CmapAppearance operations and asks the
* repository to persist the complete aggregate after style or palette changes.
*/
export class CmapAppearanceEditor {
constructor(appearance, repository, tr) {
this.appearance = appearance;
this.repository = repository;
this.tr = tr;
this.$ = (id) => document.getElementById(id);
this.installColorPickers();
this.installStyleControls();
}
/** Fill appearance fields from one existing concept placement. */
showRecord(record) {
const fallback = this.appearance.defaultValues;
const titleFontSize = cmapFontSizeInPoints(record.fontSize, fallback.fontSize);
this.$("cmap-concept-background-label").textContent = record.kind === "submap" ?
this.tr("main-concept-background-color", "Main concept background color") :
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
this.apply({
backgroundColor: cmapColorValue(record.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(record.textColor, fallback.textColor),
fontFamily: record.fontFamily || fallback.fontFamily,
fontSize: titleFontSize,
fontWeight: String(record.fontWeight || fallback.fontWeight) === "400" ? "400" : "700",
fontStyle: record.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
record.synopsisTextColor || record.textColor, fallback.synopsisTextColor),
synopsisFontFamily: record.synopsisFontFamily || record.fontFamily || fallback.synopsisFontFamily,
synopsisFontSize: cmapFontSizeInPoints(
record.synopsisFontSize || "0.84em", titleFontSize),
synopsisFontWeight: String(
record.synopsisFontWeight || record.fontWeight || fallback.synopsisFontWeight) === "700" ?
"700" : "400",
synopsisFontStyle: (record.synopsisFontStyle || record.fontStyle) === "italic" ?
"italic" : "normal",
submapBackgroundColor: cmapColorValue(
record.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(record.submapBorderColor, fallback.submapBorderColor)
});
this.renderStyleOptions();
}
/** Fill appearance fields for a new ordinary concept using the model default. */
showNewConcept() {
this.$("cmap-concept-background-label").textContent =
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.add("hidden");
this.apply(this.appearance.defaultValues);
this.renderStyleOptions();
}
/** Return normalized placement values from the appearance form. */
placementChanges(isSubmap) {
const values = this.capture();
const changes = {
backgroundColor: values.backgroundColor,
textColor: values.textColor,
fontFamily: values.fontFamily,
fontSize: `${values.fontSize}pt`,
fontWeight: values.fontWeight,
fontStyle: values.fontStyle,
synopsisTextColor: values.synopsisTextColor,
synopsisFontFamily: values.synopsisFontFamily,
synopsisFontSize: `${values.synopsisFontSize}pt`,
synopsisFontWeight: values.synopsisFontWeight,
synopsisFontStyle: values.synopsisFontStyle
};
if (isSubmap) {
changes.submapBackgroundColor = values.submapBackgroundColor;
changes.submapBorderColor = values.submapBorderColor;
}
return changes;
}
capture() {
return this.appearance.normalizeValues({
backgroundColor: this.$("cmap-concept-background").value,
textColor: this.$("cmap-concept-text-color").value,
fontFamily: this.$("cmap-concept-font-family").value,
fontSize: this.$("cmap-concept-font-size").value,
fontWeight: this.$("cmap-concept-bold").checked ? "700" : "400",
fontStyle: this.$("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: this.$("cmap-concept-synopsis-text-color").value,
synopsisFontFamily: this.$("cmap-concept-synopsis-font-family").value,
synopsisFontSize: this.$("cmap-concept-synopsis-font-size").value,
synopsisFontWeight: this.$("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: this.$("cmap-concept-synopsis-italic").checked ? "italic" : "normal",
submapBackgroundColor: this.$("cmap-submap-background").value,
submapBorderColor: this.$("cmap-submap-border").value
});
}
apply(values) {
const style = this.appearance.normalizeValues(values);
if (!style) return;
this.$("cmap-concept-background").value = style.backgroundColor;
this.$("cmap-concept-text-color").value = style.textColor;
this.selectFont(style.fontFamily);
this.$("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize);
this.$("cmap-concept-bold").checked = style.fontWeight === "700";
this.$("cmap-concept-italic").checked = style.fontStyle === "italic";
this.$("cmap-concept-synopsis-text-color").value = style.synopsisTextColor;
this.selectFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family");
this.$("cmap-concept-synopsis-font-size").value =
displayCmapFontSize(style.synopsisFontSize);
this.$("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700";
this.$("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic";
this.$("cmap-submap-background").value = style.submapBackgroundColor;
this.$("cmap-submap-border").value = style.submapBorderColor;
this.updateColorControls();
}
selectFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = this.$(selectId);
const value = fontFamily || this.appearance.defaultValues.fontFamily;
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
styleName(style) {
return style.nameKey ? this.tr(style.nameKey, style.nameKey) : style.name;
}
renderStyleOptions(selectedId = this.appearance.matchingStyleId(this.capture())) {
const styles = this.appearance.styles;
const usableId = styles.some((style) => style.id === selectedId) ? selectedId : "";
for (const select of [
this.$("cmap-concept-quick-style"),
this.$("cmap-concept-style-preset")
]) {
select.replaceChildren();
const custom = document.createElement("option");
custom.value = "";
custom.textContent = this.tr("custom-style", "Custom");
select.append(custom);
for (const style of styles) {
const option = document.createElement("option");
option.value = style.id;
option.textContent = this.styleName(style);
select.append(option);
}
select.value = usableId;
}
this.updateDeleteButton();
}
syncStyleSelection() {
const matchingId = this.appearance.matchingStyleId(this.capture());
this.$("cmap-concept-quick-style").value = matchingId;
this.$("cmap-concept-style-preset").value = matchingId;
this.updateDeleteButton();
}
updateDeleteButton() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
this.$("cmap-delete-style").disabled = !selected || selected.protected;
}
applySelectedStyle(event) {
const selectedId = event?.currentTarget?.value ??
this.$("cmap-concept-style-preset").value;
this.$("cmap-concept-quick-style").value = selectedId;
this.$("cmap-concept-style-preset").value = selectedId;
const style = this.appearance.style(selectedId);
if (style) this.apply(style.values);
this.updateDeleteButton();
}
newStyleId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return `custom-${window.crypto.randomUUID()}`;
}
} catch (_error) {
// A timestamp remains sufficient when randomUUID is unavailable.
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async saveCurrentStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
const proposedName = selected && !selected.nameKey ? selected.name : "";
const name = window.prompt(this.tr("style-name-prompt", "Name for this style"), proposedName);
if (name === null) return;
const cleanName = name.trim();
if (!cleanName) {
window.alert(this.tr("style-name-required", "Enter a style name."));
return;
}
const existing = this.appearance.styles.find((style) =>
this.styleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase());
if (existing?.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (existing && !window.confirm(this.tr(
"replace-style-confirm", 'Replace the existing style "{name}"?')
.replace("{name}", this.styleName(existing)))) return;
const previousAppearance = this.appearance.toData();
const replacement = this.appearance.putStyle({
id: existing?.id || this.newStyleId(),
name: cleanName,
values: this.capture()
});
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions(replacement.id);
}
async deleteSelectedStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
if (!selected) return;
if (selected.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (!window.confirm(this.tr(
"delete-style-confirm", 'Delete style "{name}"?')
.replace("{name}", this.styleName(selected)))) return;
const previousAppearance = this.appearance.toData();
this.appearance.deleteStyle(selected.id);
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions();
}
async store() {
try {
await this.repository.save(this.appearance);
return true;
} catch (error) {
console.warn("The CMap appearance could not be stored in the database.", error);
window.alert(this.tr(
"cmap-appearance-storage-failed",
"The CMap appearance could not be stored in the wiki database."));
return false;
}
}
openNativeColorPicker(initialColor, onInput, onCommit = null) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => {
if (onCommit) onCommit(cmapColorValue(picker.value, "#ffffff"));
window.setTimeout(cleanup, 0);
}, { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
updateColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
updateColorControls() {
for (const input of this.$("cmap-concept-panel-appearance").querySelectorAll(
".cmap-color-input")) {
this.updateColorControl(input);
}
}
updatePaletteChoices(index, color) {
for (const choice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
}
installColorPickers() {
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = this.tr(
"color-swatch-help", "Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
this.appearance.palette.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
this.updatePaletteChoice(choice, color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = this.appearance.palette[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
const previousAppearance = this.appearance.toData();
const updateColor = (newColor) => {
this.appearance.setPaletteColor(index, newColor);
this.updatePaletteChoices(index, newColor);
};
this.openNativeColorPicker(this.appearance.palette[index], updateColor, async () => {
if (!await this.store()) {
this.appearance.replace(previousAppearance);
this.updatePaletteChoices(index, previousAppearance.palette[index]);
}
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
this.openNativeColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => this.updateColorControl(input));
this.updateColorControl(input);
}
document.addEventListener("pointerdown", (event) => {
if (event.target.closest(".cmap-color-control")) return;
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
});
}
updatePaletteChoice(choice, color) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
installStyleControls() {
this.$("cmap-concept-quick-style").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-concept-style-preset").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-save-style").addEventListener("click", () => this.saveCurrentStyle());
this.$("cmap-delete-style").addEventListener("click", () => this.deleteSelectedStyle());
for (const eventName of ["input", "change"]) {
this.$("cmap-concept-panel-appearance").addEventListener(eventName, (event) => {
if (!event.target.closest(".cmap-style-manager")) this.syncStyleSelection();
});
}
}
}
@@ -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();
}
}
+546
View File
@@ -0,0 +1,546 @@
"use strict";
import { debug, elementDescription, selectionStyle, escapeHtml } from "../cmap-utils.js";
const BOUNDARY_LINE_COLOR = "#77838e";
export class CmapItemDecorator {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get canvas() { return this.editor.canvas; }
get zoomFactor() { return this.editor.zoomFactor; }
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.editor.renderItem) return this.editor.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
editPhraseInline(record) {
if (!record || record.kind !== "phrase") return;
const value = record.label || "?????";
record.node.attr("content",
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.editor.labels.relation)}">`);
record.node.redraw();
const element = record.node.element();
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
if (!input) {
record.editWhenRendered = true;
return;
}
const commit = () => {
this.editor.scheduleHistoryCommit();
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.editor.selectItem(record);
};
input.addEventListener("pointerdown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
input.value = value;
input.blur();
}
});
input.addEventListener("blur", commit, { once: true });
input.focus();
input.select();
}
applyItemTypography(record, element) {
const title = element.querySelector(".cmap-card-title");
if (title) {
title.style.color = record.textColor;
title.style.fontFamily = record.fontFamily;
title.style.fontSize = record.fontSize;
title.style.fontWeight = record.fontWeight;
title.style.fontStyle = record.fontStyle;
}
const synopsis = element.querySelector(".cmap-card-synopsis");
if (synopsis) {
synopsis.style.color = record.synopsisTextColor;
synopsis.style.fontFamily = record.synopsisFontFamily;
synopsis.style.fontSize = record.synopsisFontSize;
synopsis.style.fontWeight = record.synopsisFontWeight;
synopsis.style.fontStyle = record.synopsisFontStyle;
}
}
decorateItem(record, renderedElement = null) {
const element = renderedElement || record.node.element();
if (!element) return;
element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase");
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
element.dataset.rwCmapItemId = String(record.id);
element.style.fontFamily = record.fontFamily;
element.style.fontSize = record.fontSize;
element.style.fontWeight = record.fontWeight;
element.style.fontStyle = record.fontStyle;
element.style.overflow = "visible";
this.applyItemTypography(record, element);
if (record.fitContentPending || record.kind !== "phrase") {
this.fitItemToContent(record, element);
}
const image = element.querySelector(".cmap-card-image");
if (image && image.dataset.rwCmapFitBound !== "1") {
image.dataset.rwCmapFitBound = "1";
image.addEventListener("load", () => {
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
record.fitContentPending = true;
this.fitItemToContent(record, element);
}, { once: true });
}
const descriptionButton = element.querySelector(".rw-cmap-view-description");
if (descriptionButton && descriptionButton.dataset.rwCmapBound !== "1") {
descriptionButton.dataset.rwCmapBound = "1";
descriptionButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
descriptionButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.descriptionPageSlug && this.editor.onOpenPage) {
this.editor.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug });
}
});
}
const linkedButton = element.querySelector(".rw-cmap-open-linked");
if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") {
linkedButton.dataset.rwCmapBound = "1";
linkedButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
linkedButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.parentCmapLink) {
if (this.editor.onOpenParentCmap) this.editor.onOpenParentCmap(record);
else this.editor.openParentMap();
} else if (record.cmapSlug && this.editor.onOpenCmap) {
this.editor.onOpenCmap(record);
} else if (record.pageSlug && this.editor.onOpenPage) {
this.editor.onOpenPage(record);
}
});
}
const externalButton = element.querySelector(".rw-cmap-open-external");
if (externalButton && externalButton.dataset.rwCmapBound !== "1") {
externalButton.dataset.rwCmapBound = "1";
externalButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
externalButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.externalUrl && this.editor.onOpenExternalUrl) this.editor.onOpenExternalUrl(record);
});
}
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
id: record.id,
kind: record.kind,
element: elementDescription(element),
style: selectionStyle(element)
});
}
if (this.editor.selectedItems.has(record)) {
element.classList.add("rw-cmap-selected");
element.classList.toggle("rw-cmap-selected-primary", this.editor.selectedItem === record);
element.setAttribute("aria-selected", "true");
if (this.editor.selectedItem === record) this.ensureHandles(record, element);
}
this.ensureSubmapToggle(record, element);
debug("cmap node rendered and decorated", {
id: record.id,
kind: record.kind,
selected: this.editor.selectedItems.has(record),
element: elementDescription(element),
style: selectionStyle(element)
});
if (record.editWhenRendered) {
record.editWhenRendered = false;
queueMicrotask(() => this.editPhraseInline(record));
}
this.editor.ensureCanvasExtent(
Number(record.node.attr("x")) + Number(record.node.attr("width")),
Number(record.node.attr("y")) + Number(record.node.attr("height"))
);
if (!record.moveMembership) {
let parent = record.parentSubmap;
while (parent) {
this.editor.updateSubmapFrame(parent);
parent = parent.parentSubmap;
}
}
}
fitItemToContent(record, element) {
record.fitContentPending = false;
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
const fixedWidth = !record.autoWidth && record.kind !== "phrase";
const probe = document.createElement("div");
probe.className = element.className;
probe.innerHTML = this.itemHtml(record);
Object.assign(probe.style, {
position: "fixed",
left: "-10000px",
top: "0",
width: fixedWidth ? `${record.width}px` : "max-content",
height: "auto",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
boxSizing: "border-box",
fontFamily: record.fontFamily,
fontSize: record.fontSize,
fontWeight: record.fontWeight,
fontStyle: record.fontStyle,
lineHeight: "1.25",
overflow: "visible",
pointerEvents: "none",
transform: "none",
visibility: "hidden",
whiteSpace: "normal"
});
this.applyItemTypography(record, probe);
const content = probe.firstElementChild;
if (content) {
Object.assign(content.style, {
width: fixedWidth ? "100%" : "max-content",
height: "auto",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
overflow: "visible",
whiteSpace: "normal"
});
}
document.body.append(probe);
const bounds = probe.getBoundingClientRect();
probe.remove();
const minimumWidth = record.kind === "phrase" ? 50 : 100;
const minimumHeight = record.kind === "phrase" ? 24 : 40;
const measuredWidth = Math.ceil(bounds.width) + 4;
const measuredHeight = Math.ceil(bounds.height) + 4;
const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width;
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) :
(record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
if (nextWidth === record.width && nextHeight === record.height) return;
const beforeAutomaticLayout = this.editor.onAutomaticLayoutChange ? this.editor.historySnapshot() : null;
const previousWidth = record.width;
const previousHeight = record.height;
const attributes = { width: nextWidth, height: nextHeight };
if (record.kind === "phrase") {
attributes.x = Number(record.node.attr("x")) + ((previousWidth - nextWidth) / 2);
attributes.y = Number(record.node.attr("y")) + ((previousHeight - nextHeight) / 2);
}
record.width = nextWidth;
record.height = nextHeight;
record.node.attr(attributes);
record.node.redraw();
this.editor.redrawConnectorsFor(record);
debug("automatic item size applied", {
id: record.id,
kind: record.kind,
width: nextWidth,
height: nextHeight
});
this.editor.refreshHistorySnapshot();
if (this.editor.onAutomaticLayoutChange) {
const afterAutomaticLayout = this.editor.historySnapshot();
if (beforeAutomaticLayout !== afterAutomaticLayout) {
this.editor.onAutomaticLayoutChange({
beforeSnapshot: beforeAutomaticLayout,
afterSnapshot: afterAutomaticLayout,
itemId: record.id
});
}
}
}
decorateConnector(record, renderedElement = null) {
const element = renderedElement || record.link.element();
if (!element) return;
element.classList.add("rw-cmap-connector");
element.dataset.rwCmapConnectorId = String(record.id);
}
ensureSubmapToggle(record, element) {
let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle");
let open = element.querySelector(":scope > .rw-cmap-submap-open");
if (record.kind !== "submap") {
if (toggle) toggle.remove();
if (open) open.remove();
return;
}
if (record === this.editor.activeMapRoot) {
if (toggle) toggle.remove();
if (open) open.remove();
return;
}
const legacySeparateMap = record.separateMap && !record.cmapSlug;
if (record.expanded && !legacySeparateMap) {
if (toggle) toggle.remove();
toggle = null;
}
if (!toggle) {
if (!record.expanded || legacySeparateMap) {
toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "rw-cmap-submap-toggle";
toggle.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
toggle.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
this.editor.toggleSubmap(record);
});
element.append(toggle);
}
}
if (toggle) {
toggle.textContent = legacySeparateMap ? "↗" : "+";
toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap";
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(record.expanded));
}
if (record.cmapSlug && this.editor.onOpenStoredSubMap) {
if (!open) {
open = document.createElement("button");
open.type = "button";
open.className = "rw-cmap-submap-open";
open.textContent = "↗";
open.title = "Open as separate concept map";
open.setAttribute("aria-label", open.title);
open.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
open.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
this.editor.onOpenStoredSubMap(record);
});
element.append(open);
}
} else if (open) {
open.remove();
}
}
ensureHandles(record, element) {
if (!element.querySelector(":scope > .rw-cmap-relation-handle")) {
const relation = document.createElement("button");
relation.type = "button";
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
relation.title = this.editor.labels.createRelation;
relation.setAttribute("aria-label", this.editor.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.editor.startRelationDrag(event, record));
element.append(relation);
}
if (record.kind !== "phrase" &&
!element.querySelector(":scope > .rw-cmap-edit-handle")) {
const edit = document.createElement("button");
edit.type = "button";
edit.className = "rw-cmap-handle rw-cmap-edit-handle";
edit.title = this.editor.labels.editConcept;
edit.setAttribute("aria-label", this.editor.labels.editConcept);
edit.setAttribute("aria-hidden", "false");
edit.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
edit.addEventListener("mousedown", (event) => event.stopPropagation());
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
if (this.editor.onEditItem) this.editor.onEditItem(record);
});
element.append(edit);
}
if (record.kind !== "phrase" &&
!element.querySelector(":scope > .rw-cmap-resize-handle")) {
const resize = document.createElement("button");
resize.type = "button";
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
resize.title = this.editor.labels.resizeConcept;
resize.setAttribute("aria-label", this.editor.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.editor.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
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.editor.itemInsideActiveMap(neighbour) === inside) return neighbour;
}
return record;
}
refreshBoundaryReferences() {
if (this.editor.boundaryLayer) {
this.editor.boundaryLayer.remove();
this.editor.boundaryLayer = null;
}
if (!this.editor.activeMapRoot) return;
const surface = this.editor.surfaceElement();
if (!surface) return;
const crossings = [];
for (const connector of this.connectors) {
const sourceInside = this.editor.itemInsideActiveMap(connector.source);
const targetInside = this.editor.itemInsideActiveMap(connector.target);
if (sourceInside === targetInside) continue;
const insideRecord = sourceInside ? connector.source : connector.target;
const outsideRecord = sourceInside ? connector.target : connector.source;
const insideConcept = this.boundaryConceptFor(insideRecord, connector, true);
const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false);
if (!insideConcept || !outsideConcept || !this.editor.isItemVisible(insideConcept)) continue;
crossings.push({ connector, sourceInside, insideConcept, outsideConcept });
}
if (!crossings.length) 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", BOUNDARY_LINE_COLOR);
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.editor.boundaryLayer = layer;
const viewLeft = this.canvas.scrollLeft / this.zoomFactor;
const viewTop = this.canvas.scrollTop / this.zoomFactor;
const viewWidth = this.canvas.clientWidth / this.zoomFactor;
const viewHeight = this.canvas.clientHeight / this.zoomFactor;
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 crossing of crossings) {
const insideX = Number(crossing.insideConcept.node.attr("x")) +
(Number(crossing.insideConcept.node.attr("width")) / 2);
const insideY = Number(crossing.insideConcept.node.attr("y")) +
(Number(crossing.insideConcept.node.attr("height")) / 2);
const side = insideX <= viewLeft + (viewWidth / 2) ? "left" : "right";
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
const y = reserveY(side, insideY - 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`;
button.textContent = crossing.outsideConcept.label || "External concept";
button.title = "Open the concept map containing this connection";
button.addEventListener("click", () => {
if (this.editor.onOpenBoundaryReference) {
this.editor.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector);
}
});
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 = crossing.sourceInside ? insideX : boundaryX;
const startY = crossing.sourceInside ? insideY : boundaryY;
const endX = crossing.sourceInside ? boundaryX : insideX;
const endY = crossing.sourceInside ? boundaryY : insideY;
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke", BOUNDARY_LINE_COLOR);
path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2));
if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
svg.append(path);
}
}
updateSubmapAnchorLine(record, bounds, surface = this.editor.surfaceElement()) {
if (!surface || !bounds || record === this.editor.activeMapRoot || !record.expanded) {
if (record.submapAnchorLineElement) {
record.submapAnchorLineElement.remove();
record.submapAnchorLineElement = null;
}
return;
}
if (!record.submapAnchorLineElement) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-submap-anchor-line");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.append(path);
surface.prepend(svg);
record.submapAnchorLineElement = svg;
}
const anchor = this.editor.itemCenter(record);
const target = {
x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)),
y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom))
};
record.submapAnchorLineElement.querySelector("path")
.setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`);
}
}
@@ -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
View File
@@ -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">&#128100;</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));
}
}
+58
View File
@@ -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);
}
}
@@ -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">&#128100;</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
+29
View File
@@ -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
};