refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) }));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import "/cmap/cmap-racket-wiki.js";
|
||||
import "/js/cmap/cmap-racket-wiki.js";
|
||||
|
||||
import {
|
||||
newPageReference,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
expandNamespacedMarkdownLinks,
|
||||
expandTodoMarkup,
|
||||
expandWikiMentions,
|
||||
protectCamelCaseWikiWords,
|
||||
extractCmapEmbeds,
|
||||
restoreCmapEmbeds
|
||||
} from "./wiki/markdown.js";
|
||||
@@ -24,7 +25,7 @@ import { MailAdmin } from "./wiki/admin/mail-admin.js";
|
||||
import { loadAdminOverview } from "./wiki/admin/overview.js";
|
||||
import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
|
||||
import { UserAdmin } from "./wiki/admin/user-admin.js";
|
||||
import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
import { CmapWorkspaceController } from "./wiki/cmap/cmap-workspace-controller.js";
|
||||
import { ComboBox } from "./widgets/combobox.js";
|
||||
|
||||
(() => {
|
||||
@@ -47,6 +48,7 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
currentPage: null,
|
||||
editingNew: false,
|
||||
newPageSlug: null,
|
||||
newPageSuggestedTitle: "",
|
||||
previousView: "page-view",
|
||||
translations: {},
|
||||
translationPage: "wiki-translations",
|
||||
@@ -77,7 +79,7 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
|
||||
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
|
||||
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
|
||||
const cmapWorkspace = new CmapWorkspace(
|
||||
const cmapWorkspace = new CmapWorkspaceController(
|
||||
state,
|
||||
api,
|
||||
tr,
|
||||
@@ -434,6 +436,16 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
easyMDE.codemirror.refresh();
|
||||
}
|
||||
|
||||
function protectCamelCaseInPage() {
|
||||
if (!easyMDE) return;
|
||||
const source = easyMDE.value();
|
||||
const protectedMarkdown = protectCamelCaseWikiWords(source);
|
||||
if (protectedMarkdown === source) return;
|
||||
easyMDE.value(protectedMarkdown);
|
||||
renderEditorToc();
|
||||
$("save-status").textContent = tr("camelcase-protected", "CamelCase links protected; save the page to keep the changes.");
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Configure the single EasyMDE instance used for page editing.
|
||||
* pre : Vendor scripts and the editor textarea are loaded.
|
||||
@@ -504,6 +516,7 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
console.error(error);
|
||||
});
|
||||
}, "share-2", tr("link-concept-map", "Link to CMap")),
|
||||
toolbarButton("protect-camelcase", protectCamelCaseInPage, "ban", tr("protect-camelcase", "Protect CamelCase")),
|
||||
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", tr("upload-image", "Upload image")),
|
||||
toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")),
|
||||
toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")),
|
||||
@@ -976,12 +989,13 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
* pre : requestedSlug is null or a compact root/namespaced page reference.
|
||||
* post : Namespace, title, template and Markdown controls are initialized for creation.
|
||||
*/
|
||||
function beginNewPage(requestedSlug = null) {
|
||||
function beginNewPage(requestedSlug = null, suggestedTitle = "") {
|
||||
state.editingNew = true;
|
||||
state.currentPage = null;
|
||||
state.newPageSlug = requestedSlug;
|
||||
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
||||
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
|
||||
$("editor-title").value = translationPage ? tr("translations", "Translations") :
|
||||
(suggestedTitle || "");
|
||||
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
|
||||
$("editor-namespace").disabled = Boolean(translationPage);
|
||||
$("editor-tags").value = "";
|
||||
@@ -1580,6 +1594,12 @@ import { ComboBox } from "./widgets/combobox.js";
|
||||
}
|
||||
|
||||
function showMissingEditablePage(slug) {
|
||||
const suggestedTitle = state.newPageSuggestedTitle;
|
||||
state.newPageSuggestedTitle = "";
|
||||
if (suggestedTitle) {
|
||||
beginNewPage(slug, suggestedTitle);
|
||||
return;
|
||||
}
|
||||
state.currentPage = null;
|
||||
state.editingNew = false;
|
||||
state.newPageSlug = slug;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
|
||||
import { escapeHtml } from "../../cmap/cmap-utils.js";
|
||||
|
||||
/** Render wiki-specific HTML content for CMap editor items. */
|
||||
export class CmapEditorPresentation {
|
||||
constructor({ state, translate, canonicalPageReference, referenceIndex }) {
|
||||
this.state = state;
|
||||
this.translate = translate;
|
||||
this.canonicalPageReference = canonicalPageReference;
|
||||
this.referenceIndex = referenceIndex;
|
||||
}
|
||||
|
||||
renderItem(record) {
|
||||
const safeLabel = escapeHtml(record.label || "");
|
||||
const safeSynopsis = escapeHtml(record.synopsis || "");
|
||||
const safeKind = escapeHtml(record.kind || "concept");
|
||||
const safeImageSource = escapeHtml(record.imageSource || "");
|
||||
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
|
||||
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
|
||||
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
|
||||
.join("");
|
||||
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
|
||||
const personNames = (Array.isArray(record.tags) ? record.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
|
||||
.map((tag) => String(tag.value));
|
||||
const peopleLabel = personNames.length ?
|
||||
this.translate("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
|
||||
const personTags = personNames.length ?
|
||||
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">👤</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
|
||||
const usageCount = record.conceptId && record.kind !== "phrase" ?
|
||||
this.conceptUsage(record) : null;
|
||||
const usageLabel = usageCount === null ? "" :
|
||||
this.translate("concept-usage-count", "{count} placements across all concept maps")
|
||||
.replace("{count}", String(usageCount));
|
||||
const usage = usageCount === null ? "" :
|
||||
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
|
||||
const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || "");
|
||||
const descriptionExists = Boolean(descriptionReference &&
|
||||
this.state.pages.some((page) => page.slug === descriptionReference));
|
||||
const descriptionButton = record.descriptionPageSlug ?
|
||||
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(this.translate("view-concept-description", "View concept description"))}">I</button>` : "";
|
||||
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
|
||||
const linkedButton = linkedTarget && record.kind !== "submap" ?
|
||||
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
|
||||
const externalButton = record.externalUrl ?
|
||||
`<button type="button" class="rw-cmap-open-external" aria-label="${escapeHtml(this.translate("open-external-web-page", "Open external web page"))}">↗</button>` : "";
|
||||
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
|
||||
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
|
||||
}
|
||||
|
||||
conceptUsage(record) {
|
||||
const maps = this.referenceIndex.byConceptId.get(record.conceptId);
|
||||
const storedTotal = maps ? Array.from(maps.values()).reduce((sum, entry) =>
|
||||
sum + (typeof entry === "number" ? entry : Number(entry.count) || 0), 0) : 0;
|
||||
const editor = this.state.cmapPrototype?.editor;
|
||||
if (!editor || !editor.containsItemRecord(record)) {
|
||||
return Math.max(1, storedTotal || Number(record.usageCount) || 1);
|
||||
}
|
||||
const currentSlug = this.state.currentConceptMapSource?.slug ||
|
||||
this.state.currentConceptMap?.slug || null;
|
||||
const entry = currentSlug && maps ? maps.get(currentSlug) : null;
|
||||
const current = typeof entry === "number" ? entry : Number(entry?.count) || 0;
|
||||
return Math.max(1, storedTotal - current + (Number(record.usageCount) || 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use strict";
|
||||
|
||||
/** Render stored CMap snapshots as linked images inside wiki pages. */
|
||||
export class CmapEmbedView {
|
||||
constructor({ repository, conceptMaps, resolveReference, route, translate }) {
|
||||
this.repository = repository;
|
||||
this.conceptMaps = conceptMaps;
|
||||
this.resolveReference = resolveReference;
|
||||
this.route = route;
|
||||
this.translate = translate;
|
||||
}
|
||||
|
||||
async render(root) {
|
||||
const embeds = Array.from(root.querySelectorAll(
|
||||
".rw-cmap-embed:not([data-cmap-hydrated])"));
|
||||
for (const embed of embeds) await this.renderOne(embed);
|
||||
}
|
||||
|
||||
async renderOne(embed) {
|
||||
embed.dataset.cmapHydrated = "loading";
|
||||
const conceptMap = this.resolveReference(
|
||||
embed.dataset.cmapReference || "", this.conceptMaps());
|
||||
if (!conceptMap) {
|
||||
this.showError(embed, this.translate("concept-map-not-found", "CMap not found"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = await this.repository.load(conceptMap.slug);
|
||||
if (!stored.renderedSvg) {
|
||||
throw new Error("This embedded CMap has no rendered image yet.");
|
||||
}
|
||||
embed.replaceChildren();
|
||||
embed.dataset.cmapSlug = conceptMap.slug;
|
||||
const link = document.createElement("a");
|
||||
link.href = this.route(conceptMap.slug);
|
||||
link.className = "rw-cmap-embed-link";
|
||||
link.title = this.translate("embedded-concept-map-help", "Open this CMap.");
|
||||
const image = document.createElement("img");
|
||||
image.className = "rw-cmap-embed-image";
|
||||
image.alt = conceptMap.title;
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stored.renderedSvg)}`;
|
||||
link.append(image);
|
||||
embed.append(link);
|
||||
embed.dataset.cmapHydrated = "ready";
|
||||
} catch (error) {
|
||||
this.showError(embed, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
showError(embed, message) {
|
||||
embed.dataset.cmapHydrated = "error";
|
||||
embed.replaceChildren();
|
||||
const node = document.createElement("p");
|
||||
node.className = "error";
|
||||
node.textContent = message;
|
||||
embed.append(node);
|
||||
}
|
||||
}
|
||||
+118
-219
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
newPageReference
|
||||
newPageReference,
|
||||
pageReference,
|
||||
splitPageReference
|
||||
} from "../reference.js";
|
||||
import { cmapRoute, pageRoute } from "../routes.js";
|
||||
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
|
||||
import { CmapModel } from "../../../cmap/model/concept-map.js";
|
||||
import { CmapAppearanceRepository } from "../../../cmap/model/appearance-repository.js";
|
||||
import { CmapRepository } from "../../../cmap/model/cmap-repository.js";
|
||||
import { CmapJsonExporter } from "../../../cmap/model/json-exporter.js";
|
||||
import { CmapJsonImporter } from "../../../cmap/model/json-importer.js";
|
||||
import { CmapMarkdownExporter } from "../../../cmap/model/markdown-exporter.js";
|
||||
import { CmapSettingsRepository } from "../../../cmap/model/settings-repository.js";
|
||||
import { PeopleRepository } from "../../../cmap/model/people-repository.js";
|
||||
import { CmapAppearanceEditor } from "../../../cmap/view/appearance-editor.js";
|
||||
import { CmapModel } from "../../cmap/model/concept-map.js";
|
||||
import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js";
|
||||
import { CmapRepository } from "../../cmap/model/cmap-repository.js";
|
||||
import { CmapJsonExporter } from "../../cmap/model/json-exporter.js";
|
||||
import { CmapJsonImporter } from "../../cmap/model/json-importer.js";
|
||||
import { CmapMarkdownExporter } from "../../cmap/model/markdown-exporter.js";
|
||||
import { CmapSettingsRepository } from "../../cmap/model/settings-repository.js";
|
||||
import { CmapReferenceIndex } from "../../cmap/model/cmap-reference-index.js";
|
||||
import { PeopleRepository } from "../../cmap/model/people-repository.js";
|
||||
import { CmapAppearanceEditor } from "../../cmap/view/appearance-editor.js";
|
||||
import { ComboBox } from "../../widgets/combobox.js";
|
||||
import { DescriptionPreview } from "../../widgets/description-preview.js";
|
||||
import { PopupMenu } from "../../widgets/popup-menu.js";
|
||||
@@ -22,6 +25,8 @@ import { CmapHistoryDialog } from "./dialogs/history-dialog.js";
|
||||
import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js";
|
||||
import { CmapPeopleDialog } from "./dialogs/people-dialog.js";
|
||||
import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
|
||||
import { CmapEmbedView } from "./cmap-embed-view.js";
|
||||
import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
|
||||
|
||||
/**
|
||||
* Own the complete browser-side CMap workspace.
|
||||
@@ -30,7 +35,7 @@ import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
|
||||
* persistence, history and browser event handling. The wiki application
|
||||
* supplies only the concrete services needed to cross the module boundary.
|
||||
*/
|
||||
export class CmapWorkspace {
|
||||
export class CmapWorkspaceController {
|
||||
constructor(
|
||||
state,
|
||||
api,
|
||||
@@ -59,6 +64,20 @@ export class CmapWorkspace {
|
||||
const cmapRepository = new CmapRepository(api);
|
||||
const appearanceRepository = new CmapAppearanceRepository(api);
|
||||
const settingsRepository = new CmapSettingsRepository(api);
|
||||
const cmapReferenceIndex = new CmapReferenceIndex();
|
||||
const presentation = new CmapEditorPresentation({
|
||||
state,
|
||||
translate: tr,
|
||||
canonicalPageReference,
|
||||
referenceIndex: cmapReferenceIndex
|
||||
});
|
||||
const embedView = new CmapEmbedView({
|
||||
repository: cmapRepository,
|
||||
conceptMaps: () => state.conceptMaps,
|
||||
resolveReference: cmapMentionTarget,
|
||||
route: cmapRoute,
|
||||
translate: tr
|
||||
});
|
||||
const peopleRepository = new PeopleRepository(api);
|
||||
let appearanceEditor = null;
|
||||
let conceptDialog = null;
|
||||
@@ -110,92 +129,10 @@ export class CmapWorkspace {
|
||||
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
|
||||
cmapEmbedHydrationTimer = window.setTimeout(() => {
|
||||
cmapEmbedHydrationTimer = null;
|
||||
hydrateCmapEmbeds(document).catch((error) => console.error(error));
|
||||
embedView.render(document).catch((error) => console.error(error));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Render all pending CMap embeds below root as read-only CMap editors. */
|
||||
async function hydrateCmapEmbeds(root) {
|
||||
const embeds = Array.from(root.querySelectorAll(
|
||||
".rw-cmap-embed:not([data-cmap-hydrated])"));
|
||||
for (const embed of embeds) {
|
||||
embed.dataset.cmapHydrated = "loading";
|
||||
const conceptMap = cmapMentionTarget(
|
||||
embed.dataset.cmapReference || "",
|
||||
state.conceptMaps);
|
||||
if (!conceptMap) {
|
||||
embed.dataset.cmapHydrated = "error";
|
||||
embed.replaceChildren();
|
||||
const message = document.createElement("p");
|
||||
message.className = "error";
|
||||
message.textContent = tr("concept-map-not-found", "CMap not found");
|
||||
embed.append(message);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stored = await cmapRepository.load(conceptMap.slug);
|
||||
embed.replaceChildren();
|
||||
embed.dataset.cmapSlug = conceptMap.slug;
|
||||
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = conceptMap.title;
|
||||
const hint = document.createElement("span");
|
||||
hint.textContent = tr(
|
||||
"embedded-concept-map-help", "Double-click to open this CMap.");
|
||||
header.append(title, hint);
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "rw-cmap-embed-viewport";
|
||||
const canvas = document.createElement("div");
|
||||
canvas.className =
|
||||
"cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
|
||||
viewport.append(canvas);
|
||||
embed.append(header, viewport);
|
||||
const editor = window.RacketWikiCmap.createEditor(canvas, {
|
||||
renderItem: (record) => cmapNodeHtml(record),
|
||||
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
|
||||
});
|
||||
editor.loadModel(stored.model);
|
||||
const visible = editor.visibleItemRecords();
|
||||
if (visible.length) {
|
||||
const left = Math.min(...visible.map((item) =>
|
||||
Number(item.node.attr("x")))) - 24;
|
||||
const top = Math.min(...visible.map((item) =>
|
||||
Number(item.node.attr("y")))) - 24;
|
||||
const right = Math.max(...visible.map((item) =>
|
||||
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
|
||||
const bottom = Math.max(...visible.map((item) =>
|
||||
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
|
||||
const availableWidth = Math.max(320, embed.clientWidth - 2);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
availableWidth / Math.max(1, right - left),
|
||||
520 / Math.max(1, bottom - top));
|
||||
editor.zoomFactor = scale;
|
||||
editor.map.zoom(scale);
|
||||
viewport.style.height =
|
||||
`${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
|
||||
window.requestAnimationFrame(() => {
|
||||
viewport.scrollLeft = Math.max(0, left * scale);
|
||||
viewport.scrollTop = Math.max(0, top * scale);
|
||||
});
|
||||
}
|
||||
embed.dataset.cmapHydrated = "ready";
|
||||
embed.addEventListener("dblclick", () => {
|
||||
navigateToHash(cmapRoute(conceptMap.slug))
|
||||
.catch((error) => console.error(error));
|
||||
});
|
||||
} catch (error) {
|
||||
embed.dataset.cmapHydrated = "error";
|
||||
embed.replaceChildren();
|
||||
const message = document.createElement("p");
|
||||
message.className = "error";
|
||||
message.textContent = error.message;
|
||||
embed.append(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startCmapSlug() {
|
||||
return settingsRepository.startCmapSlug;
|
||||
}
|
||||
@@ -222,83 +159,14 @@ export class CmapWorkspace {
|
||||
return state.cmapPrototype;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Render one concept card inside an ionstage/cmap node.
|
||||
* pre : record contains label, synopsis and kind.
|
||||
* result : Safe HTML used as the node's content.
|
||||
*/
|
||||
function cmapNodeHtml(record) {
|
||||
const safeLabel = escapeHtml(record.label || "");
|
||||
const safeSynopsis = escapeHtml(record.synopsis || "");
|
||||
const safeKind = escapeHtml(record.kind || "concept");
|
||||
const safeImageSource = escapeHtml(record.imageSource || "");
|
||||
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
|
||||
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
|
||||
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
|
||||
.join("");
|
||||
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
|
||||
const personNames = (Array.isArray(record.tags) ? record.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
|
||||
.map((tag) => String(tag.value));
|
||||
const peopleLabel = personNames.length ?
|
||||
tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
|
||||
const personTags = personNames.length ?
|
||||
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">👤</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
|
||||
const usageCount = record.conceptId && record.kind !== "phrase" ?
|
||||
effectiveCmapConceptUsage(record) : null;
|
||||
const usageLabel = usageCount === null ? "" :
|
||||
tr("concept-usage-count", "{count} placements across all concept maps")
|
||||
.replace("{count}", String(usageCount));
|
||||
const usage = usageCount === null ? "" :
|
||||
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
|
||||
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
|
||||
const descriptionExists = Boolean(descriptionReference &&
|
||||
state.pages.some((page) => page.slug === descriptionReference));
|
||||
const descriptionButton = record.descriptionPageSlug ?
|
||||
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(tr("view-concept-description", "View concept description"))}">I</button>` : "";
|
||||
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
|
||||
const linkedButton = linkedTarget && record.kind !== "submap" ?
|
||||
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
|
||||
const externalButton = record.externalUrl ?
|
||||
`<button type="button" class="rw-cmap-open-external" title="${escapeHtml(tr("open-external-web-page", "Open external web page"))}" aria-label="${escapeHtml(tr("open-external-web-page", "Open external web page"))}">↗</button>` : "";
|
||||
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
|
||||
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
|
||||
return presentation.renderItem(record);
|
||||
}
|
||||
|
||||
function effectiveCmapConceptUsage(record) {
|
||||
const byMap = state.cmapConceptUsage.get(record.conceptId);
|
||||
const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0;
|
||||
const prototype = cmapPrototypeState();
|
||||
const isActiveEditorRecord = Boolean(
|
||||
prototype.editor && prototype.editor.containsItemRecord(record));
|
||||
if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1);
|
||||
const currentSlug = state.currentConceptMapSource?.slug ||
|
||||
state.currentConceptMap?.slug || null;
|
||||
const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0;
|
||||
return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1));
|
||||
return presentation.conceptUsage(record);
|
||||
}
|
||||
|
||||
function hideCmapDescriptionTooltip() {
|
||||
cmapDescriptionPreview.hide();
|
||||
}
|
||||
|
||||
async function showCmapDescriptionTooltip(button) {
|
||||
if (!button.classList.contains("is-filled")) return;
|
||||
const itemElement = button.closest("[data-rw-cmap-item-id]");
|
||||
const prototype = cmapPrototypeState();
|
||||
const record = itemElement && prototype.editor ?
|
||||
prototype.editor.itemRecord(itemElement.dataset.rwCmapItemId) : null;
|
||||
if (!record || !record.descriptionPageSlug) return;
|
||||
|
||||
const reference = canonicalPageReference(record.descriptionPageSlug);
|
||||
await cmapDescriptionPreview.show(button, reference, tr("loading", "Loading…"));
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Derive a compact synopsis from the currently opened wiki page.
|
||||
* pre : state.currentPage may be #f/null when no page is open.
|
||||
* result : Plain text of at most about 150 characters.
|
||||
*/
|
||||
function currentPageSynopsis() {
|
||||
if (!state.currentPage) return "";
|
||||
const container = document.createElement("div");
|
||||
@@ -307,12 +175,6 @@ export class CmapWorkspace {
|
||||
return text.length > 150 ? `${text.slice(0, 147)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Add a concept-like item to the active CMap prototype.
|
||||
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
|
||||
* post : The item is draggable, selectable, resizable and linkable.
|
||||
* result : The item record created by the interaction layer.
|
||||
*/
|
||||
function addCmapPrototypeNode(options = {}) {
|
||||
const prototype = cmapPrototypeState();
|
||||
if (!prototype.editor) return null;
|
||||
@@ -350,10 +212,36 @@ export class CmapWorkspace {
|
||||
|
||||
function openLinkedCmap(record) {
|
||||
if (!record || !record.cmapSlug) return false;
|
||||
rememberActiveCmapContext();
|
||||
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
|
||||
return true;
|
||||
}
|
||||
|
||||
function rememberActiveCmapContext() {
|
||||
const prototype = cmapPrototypeState();
|
||||
const editor = prototype.editor;
|
||||
const mapSlug = state.currentConceptMap?.slug;
|
||||
const root = editor?.activeMapRoot;
|
||||
if (!mapSlug || !root) return;
|
||||
const context = {
|
||||
mapSlug,
|
||||
rootItemId: Number(root.id)
|
||||
};
|
||||
history.replaceState({ ...history.state, cmapContext: context }, "", location.href);
|
||||
}
|
||||
|
||||
async function restoreActiveCmapContext(slug) {
|
||||
const context = history.state?.cmapContext;
|
||||
if (!context || context.mapSlug !== slug) return false;
|
||||
const prototype = cmapPrototypeState();
|
||||
const root = prototype.editor?.itemRecord(context.rootItemId);
|
||||
if (root && root.kind === "submap" && root.separateMap) {
|
||||
prototype.editor.openSubmapMap(root);
|
||||
}
|
||||
history.replaceState({ ...history.state, cmapContext: null }, "", location.href);
|
||||
return true;
|
||||
}
|
||||
|
||||
function openParentCmap() {
|
||||
const source = state.currentConceptMapSource;
|
||||
if (source && source.slug) {
|
||||
@@ -375,7 +263,10 @@ export class CmapWorkspace {
|
||||
|
||||
function currentConceptDialogResources() {
|
||||
const prototype = cmapPrototypeState();
|
||||
const namespaceModel = state.currentConceptMapSource?.model ||
|
||||
state.currentConceptMap?.model;
|
||||
return {
|
||||
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
|
||||
pages: state.pages,
|
||||
conceptMaps: state.conceptMaps,
|
||||
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
|
||||
@@ -745,7 +636,7 @@ export class CmapWorkspace {
|
||||
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
|
||||
cmapStylesheet: Array.from(document.styleSheets)
|
||||
.map((sheet) => sheet.href)
|
||||
.find((href) => href && href.includes("/cmap/cmap.css")) || null
|
||||
.find((href) => href && href.includes("/js/cmap/cmap.css")) || null
|
||||
});
|
||||
|
||||
if (!window.RacketWikiCmap) {
|
||||
@@ -759,9 +650,23 @@ export class CmapWorkspace {
|
||||
const prototype = cmapPrototypeState();
|
||||
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
|
||||
renderItem: (record) => cmapNodeHtml(record),
|
||||
boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
|
||||
state.currentConceptMap?.title || "",
|
||||
onOpenPage: (record) => {
|
||||
if (record.pageSlug) {
|
||||
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
|
||||
const namespace = state.currentConceptMapSource?.model?.metadata()?.namespace ||
|
||||
state.currentConceptMap?.model?.metadata()?.namespace || "";
|
||||
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
|
||||
const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null;
|
||||
const target = namespace && page?.namespace === "cmap" ?
|
||||
pageReference(namespace, page.slug) : record.pageSlug;
|
||||
if (isDescriptionReference && target !== record.pageSlug) {
|
||||
record.pageSlug = target;
|
||||
}
|
||||
if (!state.pages.some((pageRecord) => pageRecord.slug === target)) {
|
||||
state.newPageSuggestedTitle = record.label || "";
|
||||
}
|
||||
navigateToHash(pageRoute(target)).catch((error) => console.error(error));
|
||||
}
|
||||
},
|
||||
onOpenCmap: (record) => {
|
||||
@@ -934,48 +839,14 @@ export class CmapWorkspace {
|
||||
})
|
||||
]);
|
||||
state.conceptMaps = conceptMaps;
|
||||
state.cmapConceptUsage = new Map();
|
||||
state.cmapConceptIdsByName = new Map();
|
||||
state.cmapPageConcepts = new Map();
|
||||
const pageByConcept = new Map();
|
||||
for (const placement of placements) {
|
||||
if (!placement?.conceptId || !placement.pageSlug) continue;
|
||||
pageByConcept.set(
|
||||
placement.conceptId,
|
||||
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
|
||||
}
|
||||
for (const placement of placements) {
|
||||
if (!placement || !placement.conceptId || !placement.cmapSlug) continue;
|
||||
const nameKey = normalizeCmapConceptName(placement.label);
|
||||
if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId);
|
||||
if (!state.cmapConceptUsage.has(placement.conceptId)) {
|
||||
state.cmapConceptUsage.set(placement.conceptId, new Map());
|
||||
}
|
||||
state.cmapConceptUsage.get(placement.conceptId)
|
||||
.set(placement.cmapSlug, Number(placement.count) || 0);
|
||||
|
||||
const pageKey = pageByConcept.get(placement.conceptId);
|
||||
if (!pageKey) continue;
|
||||
if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map());
|
||||
const pageConcepts = state.cmapPageConcepts.get(pageKey);
|
||||
if (!pageConcepts.has(placement.conceptId)) {
|
||||
pageConcepts.set(placement.conceptId, {
|
||||
conceptId: placement.conceptId,
|
||||
label: placement.label || tr("concept", "Concept"),
|
||||
count: 0,
|
||||
maps: new Map()
|
||||
});
|
||||
}
|
||||
const concept = pageConcepts.get(placement.conceptId);
|
||||
const count = Number(placement.count) || 0;
|
||||
concept.count += count;
|
||||
const existingMap = concept.maps.get(placement.cmapSlug);
|
||||
concept.maps.set(placement.cmapSlug, {
|
||||
slug: placement.cmapSlug,
|
||||
title: placement.cmapTitle || placement.cmapSlug,
|
||||
count: count + (existingMap?.count || 0)
|
||||
});
|
||||
}
|
||||
cmapReferenceIndex.load(
|
||||
placements,
|
||||
conceptMaps,
|
||||
canonicalPageReference,
|
||||
normalizeCmapConceptName);
|
||||
state.cmapConceptUsage = cmapReferenceIndex.byConceptId;
|
||||
state.cmapConceptIdsByName = cmapReferenceIndex.byName;
|
||||
state.cmapPageConcepts = cmapReferenceIndex.byPage;
|
||||
renderConceptMapSelector();
|
||||
const prototype = cmapPrototypeState();
|
||||
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
|
||||
@@ -989,6 +860,17 @@ export class CmapWorkspace {
|
||||
return JSON.stringify(prototype.editor.toDocument());
|
||||
}
|
||||
|
||||
function currentCmapRenderedSvg() {
|
||||
const editor = cmapPrototypeState().editor;
|
||||
if (!editor || typeof editor.snapshotSvg !== "function") return "";
|
||||
try {
|
||||
return editor.snapshotSvg();
|
||||
} catch (error) {
|
||||
console.warn("The CMap SVG snapshot could not be created.", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function currentCmapStorageMap() {
|
||||
return state.currentConceptMapSource || state.currentConceptMap;
|
||||
}
|
||||
@@ -1000,8 +882,14 @@ export class CmapWorkspace {
|
||||
function cmapHasUnsavedChanges() {
|
||||
if ($("cmap-view").classList.contains("hidden")) return false;
|
||||
const currentSnapshot = currentCmapSnapshot();
|
||||
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
|
||||
currentSnapshot !== state.cmapSavedSnapshot;
|
||||
if (currentSnapshot === null || state.cmapSavedSnapshot === null) return false;
|
||||
if (currentSnapshot === state.cmapSavedSnapshot) return false;
|
||||
const editor = cmapPrototypeState().editor;
|
||||
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
|
||||
state.cmapSavedSnapshot = currentSnapshot;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelCmapAutosave() {
|
||||
@@ -1249,6 +1137,7 @@ export class CmapWorkspace {
|
||||
if (currentModel?.derivedView()) {
|
||||
const metadata = currentModel.metadata();
|
||||
return {
|
||||
namespace: metadata.namespace || "",
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
summary: metadata.summary || "",
|
||||
explanationPageSlug: metadata.explanationPageSlug || ""
|
||||
@@ -1269,6 +1158,13 @@ export class CmapWorkspace {
|
||||
const conceptMap = state.currentConceptMap;
|
||||
const editor = cmapPrototypeState().editor;
|
||||
if (!conceptMap || !editor) return false;
|
||||
const legacyExplanation = `cmap:${conceptMap.slug}`;
|
||||
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
|
||||
metadata = {
|
||||
...metadata,
|
||||
explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}`
|
||||
};
|
||||
}
|
||||
|
||||
if (conceptMap.model.derivedView()) {
|
||||
const updatedModel = conceptMap.model.withMetadata(metadata);
|
||||
@@ -1430,6 +1326,7 @@ export class CmapWorkspace {
|
||||
}
|
||||
|
||||
const model = prototype.editor.currentModel();
|
||||
const renderedSvg = currentCmapRenderedSvg();
|
||||
const conceptMapAtStart = currentCmapStorageMap();
|
||||
let saveSucceeded = false;
|
||||
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
|
||||
@@ -1442,7 +1339,7 @@ export class CmapWorkspace {
|
||||
showCmapStatus("");
|
||||
return false;
|
||||
}
|
||||
state.currentConceptMap = await cmapRepository.create(title.trim(), model);
|
||||
state.currentConceptMap = await cmapRepository.create(title.trim(), model, null, { renderedSvg });
|
||||
const savedRoute = cmapRoute(state.currentConceptMap.slug);
|
||||
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
|
||||
state.cmapGuardHash = savedRoute;
|
||||
@@ -1456,7 +1353,8 @@ export class CmapWorkspace {
|
||||
tr("automatic-save", "Automatic save") :
|
||||
tr("manual-save", "Manual save")),
|
||||
snapshot: snapshotVersion,
|
||||
saveKind: effectiveHistoryMode
|
||||
saveKind: effectiveHistoryMode,
|
||||
renderedSvg
|
||||
});
|
||||
if (state.currentConceptMapSource &&
|
||||
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
|
||||
@@ -1510,6 +1408,7 @@ export class CmapWorkspace {
|
||||
await loadConceptMaps();
|
||||
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
|
||||
await openStoredConceptMap(requestedSlug);
|
||||
if (await restoreActiveCmapContext(requestedSlug)) markCurrentCmapSaved();
|
||||
} else if (requestedSlug) {
|
||||
state.currentConceptMap = null;
|
||||
state.currentConceptMapSource = null;
|
||||
@@ -46,6 +46,7 @@ export class CmapConceptDialog {
|
||||
open(record, resources) {
|
||||
if (!record || record.kind === "phrase") return;
|
||||
this.record = record;
|
||||
this.resources = resources;
|
||||
this.createContext = null;
|
||||
this.imageSource = record.imageSource || "";
|
||||
this.imageRead = Promise.resolve();
|
||||
@@ -72,6 +73,7 @@ export class CmapConceptDialog {
|
||||
/** Open the dialog for a new concept at the supplied editor context. */
|
||||
openNew(createContext, resources) {
|
||||
this.record = null;
|
||||
this.resources = resources;
|
||||
this.createContext = createContext;
|
||||
this.imageSource = "";
|
||||
this.imageRead = Promise.resolve();
|
||||
@@ -208,7 +210,8 @@ export class CmapConceptDialog {
|
||||
const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page");
|
||||
const descriptionText = descriptionInput.value.trim();
|
||||
const descriptionPage = descriptionText ? newPageReference(descriptionText) :
|
||||
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
|
||||
pageReference(this.resources?.cmapNamespace || "cmap",
|
||||
splitPageReference(newPageReference(label) || "concept").slug);
|
||||
if (!descriptionPage) {
|
||||
this.tabs.select("content");
|
||||
descriptionInput.setCustomValidity(
|
||||
|
||||
@@ -11,6 +11,7 @@ export class CmapMetadataDialog {
|
||||
this.normalizePageReference = normalizePageReference;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.summary = dialog.querySelector("#cmap-metadata-summary");
|
||||
this.namespace = dialog.querySelector("#cmap-metadata-namespace");
|
||||
this.tags = dialog.querySelector("#cmap-metadata-tags");
|
||||
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
|
||||
this.saveHandler = null;
|
||||
@@ -31,6 +32,7 @@ export class CmapMetadataDialog {
|
||||
}
|
||||
|
||||
open(metadata) {
|
||||
this.namespace.value = metadata.namespace || "";
|
||||
this.summary.value = metadata.summary || "";
|
||||
this.tags.value = (metadata.tags || []).join(", ");
|
||||
this.explanationPage.value = metadata.explanationPageSlug || "";
|
||||
@@ -40,6 +42,14 @@ export class CmapMetadataDialog {
|
||||
}
|
||||
|
||||
metadata() {
|
||||
const namespace = this.namespace.value.trim().toLocaleLowerCase();
|
||||
if (namespace && !/^[\p{L}\p{N}][\p{L}\p{N}-]*$/u.test(namespace)) {
|
||||
this.namespace.setCustomValidity(
|
||||
this.tr("invalid-concept-map-namespace", "Use letters, numbers and hyphens for the namespace."));
|
||||
this.namespace.reportValidity();
|
||||
return null;
|
||||
}
|
||||
this.namespace.setCustomValidity("");
|
||||
const explanationInput = this.explanationPage.value.trim();
|
||||
const explanationPageSlug = explanationInput ?
|
||||
this.normalizePageReference(explanationInput) : "";
|
||||
@@ -50,6 +60,7 @@ export class CmapMetadataDialog {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
namespace,
|
||||
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
summary: this.summary.value.trim(),
|
||||
explanationPageSlug
|
||||
|
||||
@@ -182,6 +182,34 @@ function positionIsProtected(start, end, ranges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Prefix every unprotected CamelCase WikiWord with the literal escape marker. */
|
||||
function protectCamelCaseWikiWords(markdown) {
|
||||
const wikiWordPattern = /(?<![!\p{L}\p{N}._-])((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-])/gu;
|
||||
let fence = null;
|
||||
return String(markdown || "").split("\n").map((line) => {
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1].charAt(0);
|
||||
fence = fence === null ? marker : (fence === marker ? null : fence);
|
||||
return line;
|
||||
}
|
||||
if (fence !== null || /^\s{4}/.test(line)) return line;
|
||||
|
||||
const ranges = markdownProtectedRanges(line);
|
||||
const matches = Array.from(line.matchAll(wikiWordPattern));
|
||||
let result = line;
|
||||
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
||||
const match = matches[index];
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (!positionIsProtected(start, end, ranges)) {
|
||||
result = result.slice(0, start) + `!${match[0]}` + result.slice(end);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
|
||||
function literalWikiWord(namespace, wikiWord) {
|
||||
const characters = Array.from(wikiWord);
|
||||
@@ -335,6 +363,7 @@ export {
|
||||
expandNamespacedMarkdownLinks,
|
||||
expandTodoMarkup,
|
||||
expandWikiMentions,
|
||||
protectCamelCaseWikiWords,
|
||||
extractCmapEmbeds,
|
||||
restoreCmapEmbeds
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user