Files

2972 lines
117 KiB
JavaScript

/*
* 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";
const debugPrefix = "[racket-wiki:cmap 0.2.99]";
function debug(message, details) {
if (details === undefined) {
console.info(debugPrefix, message);
return;
}
console.info(debugPrefix, message, details);
}
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
};
}
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
};
}
debug("cmap-racket-wiki.js loaded", {
script: document.currentScript ? document.currentScript.src : null,
cmapAvailable: typeof window.Cmap === "function",
stylesheets: Array.from(document.styleSheets || [])
.map((sheet) => sheet.href)
.filter((href) => href && href.includes("cmap.css"))
});
//////////////////////////////////////////////////////////////////////////////
// Small helpers
//////////////////////////////////////////////////////////////////////////////
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
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}`}`;
}
function newConceptId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
try {
return `concept-${window.crypto.randomUUID()}`;
} catch (_error) {
// randomUUID can be exposed but forbidden in an insecure/file context.
}
}
return `concept-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
//////////////////////////////////////////////////////////////////////////////
// Editor
//////////////////////////////////////////////////////////////////////////////
/**
* goal : Add the wiki editor model to the bundled CMap drawing component.
* pre : canvas is a DOM element and window.Cmap is available.
* post : Concepts and linking phrases can be selected and connected by
* direct manipulation.
* result : A CmapEditor instance.
*/
class CmapEditor {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.CmapFactory = options.Cmap || window.Cmap;
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.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.labels = {
createRelation: options.createRelationLabel || "Create relation",
editConcept: options.editConceptLabel || "Edit concept",
resizeConcept: options.resizeConceptLabel || "Resize concept",
relation: options.relationLabel || "Relation"
};
this.map = this.CmapFactory(canvas);
this.map.onSelection((component, event) => this.handleMapSelection(component, event));
this.map.onActivation((component, event) => this.handleMapActivation(component, event));
this.items = [];
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.activeMapRoot = null;
this.mapHistory = [];
this.selectedItem = null;
this.selectedItems = new Set();
this.selectedConnector = null;
this.copiedConceptIds = [];
this.destroyed = false;
this.nextId = 1;
this.nextConnectorId = 1;
this.nextGroupId = 1;
this.dragRelation = null;
this.zoomFactor = 1;
this.marqueeMouseDownHandler = null;
this.activeMarqueeCleanup = null;
this.undoStack = [];
this.redoStack = [];
this.historySnapshotValue = null;
this.historyTimer = null;
this.historyReady = false;
this.historyRestoring = false;
this.historyLimit = 100;
this.boundaryLayer = null;
this.boundaryScrollHandler = () => this.refreshBoundaryReferences();
this.canvas.addEventListener("scroll", this.boundaryScrollHandler, { passive: true });
this.installMarqueeSelection();
debug("editor created", {
canvas: elementDescription(canvas),
cmapFactoryAvailable: typeof this.CmapFactory === "function"
});
}
historySnapshot() {
return JSON.stringify(this.toDocument());
}
notifyHistory() {
if (this.onHistoryChange) {
this.onHistoryChange({
canUndo: this.canUndo(),
canRedo: this.canRedo()
});
}
}
resetHistory() {
if (this.historyTimer !== null) {
window.clearTimeout(this.historyTimer);
this.historyTimer = null;
}
this.undoStack = [];
this.redoStack = [];
this.historyReady = true;
this.historySnapshotValue = this.historySnapshot();
this.notifyHistory();
}
scheduleHistoryCommit() {
if (!this.historyReady || this.historyRestoring) return;
if (this.historyTimer !== null) window.clearTimeout(this.historyTimer);
this.historyTimer = window.setTimeout(() => {
this.historyTimer = null;
this.commitHistory();
}, 0);
}
refreshHistorySnapshot() {
if (!this.historyReady || this.historyRestoring || this.historyTimer !== null) return;
this.historySnapshotValue = this.historySnapshot();
}
commitHistory() {
if (!this.historyReady || this.historyRestoring) return false;
if (this.historyTimer !== null) {
window.clearTimeout(this.historyTimer);
this.historyTimer = null;
}
const nextSnapshot = this.historySnapshot();
if (nextSnapshot === this.historySnapshotValue) return false;
if (this.historySnapshotValue !== null) {
this.undoStack.push(this.historySnapshotValue);
if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
}
this.historySnapshotValue = nextSnapshot;
this.redoStack = [];
this.notifyHistory();
return true;
}
canUndo() {
return this.undoStack.length > 0;
}
canRedo() {
return this.redoStack.length > 0;
}
clearDocument() {
this.clearSelection(false);
for (const connector of this.connectors) connector.link.remove();
for (const record of this.items) {
if (record.submapFrameElement) record.submapFrameElement.remove();
if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove();
record.node.remove();
}
this.items = [];
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.activeMapRoot = null;
this.mapHistory = [];
this.nextId = 1;
this.nextConnectorId = 1;
if (this.boundaryLayer) {
this.boundaryLayer.remove();
this.boundaryLayer = null;
}
this.nextGroupId = 1;
}
restoreHistorySnapshot(snapshot) {
const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null;
const mapHistoryIds = this.mapHistory.map((record) => record.id);
this.historyRestoring = true;
try {
this.clearDocument();
this.loadDocument(JSON.parse(snapshot));
this.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null;
this.mapHistory = mapHistoryIds
.map((id) => this.items.find((item) => item.id === id))
.filter(Boolean);
this.applyCurrentContextLayout();
const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
} finally {
this.historyRestoring = false;
}
this.historySnapshotValue = snapshot;
this.notifySelection();
this.notifyHistory();
}
undo() {
this.commitHistory();
if (!this.canUndo()) return false;
this.redoStack.push(this.historySnapshotValue);
const snapshot = this.undoStack.pop();
this.restoreHistorySnapshot(snapshot);
debug("undo applied", {
undoCount: this.undoStack.length,
redoCount: this.redoStack.length
});
return true;
}
redo() {
this.commitHistory();
if (!this.canRedo()) return false;
this.undoStack.push(this.historySnapshotValue);
const snapshot = this.redoStack.pop();
this.restoreHistorySnapshot(snapshot);
debug("redo applied", {
undoCount: this.undoStack.length,
redoCount: this.redoStack.length
});
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) : [],
descriptionPageSlug: kind === "phrase" ? null :
(options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)),
pageSlug: options.pageSlug || null,
cmapSlug: options.cmapSlug || 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"
}])) : {},
submapFrameElement: null,
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" && kind !== "submap" ?
this.items.filter((item) => item.conceptId === record.conceptId &&
item.kind !== "phrase" && item.kind !== "submap").length + 1 : null;
const node = this.map.node({
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.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" && item.kind !== "submap");
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 root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root";
}
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.mapContextKey();
for (const record of this.items.filter((item) => this.isEffectiveItemVisible(item))) {
record.layouts[context] = this.itemLayout(record);
}
}
applyCurrentContextLayout() {
const context = this.mapContextKey();
const visible = this.items.filter((item) => this.isEffectiveItemVisible(item));
let offset = { x: 0, y: 0 };
if (this.activeMapRoot && !this.activeMapRoot.layouts[context]) {
const rootLayout = this.itemLayout(this.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.itemHtml(record),
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
textColor: record.textColor
});
record.node.redraw();
}
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
window.requestAnimationFrame(() => {
if (!this.destroyed && this.canvas.isConnected) this.refreshConnectorGeometry();
});
}
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 Boolean(this.activeMapRoot &&
(record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot)));
}
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() {
if (this.boundaryLayer) {
this.boundaryLayer.remove();
this.boundaryLayer = null;
}
if (!this.activeMapRoot) return;
const surface = this.surfaceElement();
if (!surface) return;
const crossings = [];
for (const connector of this.connectors) {
const sourceInside = this.itemInsideActiveMap(connector.source);
const targetInside = this.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.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", "#4a5560");
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.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 outsideX = Number(crossing.outsideConcept.node.attr("x")) +
(Number(crossing.outsideConcept.node.attr("width")) / 2);
const side = outsideX < insideX ? "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.onOpenBoundaryReference) {
this.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", crossing.connector.lineColor || "#4a5560");
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);
}
}
isDescendantOf(record, submap) {
let parent = record.parentSubmap;
while (parent) {
if (parent === submap) return true;
parent = parent.parentSubmap;
}
return false;
}
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;
}
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));
}
canHideSelectionInCurrentContext() {
return !this.activeMapRoot && this.selectedAll().some((item) => item.parentSubmap &&
item.kind !== "phrase" && this.isItemVisible(item));
}
hideSelectionInCurrentContext() {
const context = this.mapContextKey();
if (this.activeMapRoot) return false;
const selected = this.selectedAll().filter((item) => item.parentSubmap &&
item.kind !== "phrase" && this.isItemVisible(item));
if (!selected.length) return false;
this.scheduleHistoryCommit();
for (const item of selected) item.hiddenContexts.add(context);
this.clearSelection();
this.refreshSubmapVisibility();
if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext());
return true;
}
showItemInCurrentContext(record) {
if (!record) return false;
const context = this.mapContextKey();
if (!record.hiddenContexts.has(context)) return false;
this.scheduleHistoryCommit();
record.hiddenContexts.delete(context);
this.refreshSubmapVisibility();
if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext());
return true;
}
ensureSubmapContents(record) {
if (record.submapInitialized) return;
record.submapInitialized = true;
if (this.onPopulateSubMap) this.onPopulateSubMap(record, this);
}
toggleSubmap(record, expanded = !record.expanded) {
if (!record || record.kind !== "submap") return false;
// Legacy embedded pages had only an "open" state. New shared sub-CMaps
// keep their expand/collapse state in the parent and use a separate
// button for the standalone route.
if (record.separateMap && !record.cmapSlug) {
record.expanded = false;
if (record === this.activeMapRoot) {
this.refreshSubmapVisibility();
return false;
}
return this.openSubmapMap(record);
}
// Persist the complete placement before hiding descendants. Reapplying
// the same document-local context on expansion guarantees that a
// submap opens at the same coordinates and dimensions every time.
this.saveCurrentContextLayout();
if (expanded) this.ensureSubmapContents(record);
record.expanded = Boolean(expanded);
if (record.expanded) this.applyCurrentContextLayout();
else this.refreshSubmapVisibility();
const element = record.node.element();
if (element) this.ensureSubmapToggle(record, element);
debug("submap toggled", {
id: record.id,
expanded: record.expanded,
childCount: this.items.filter((item) => item.parentSubmap === record).length
});
if (this.onOpenSubMap) this.onOpenSubMap(record, record.expanded);
this.scheduleHistoryCommit();
return record.expanded;
}
openSubmapMap(record) {
if (!record || record.kind !== "submap" || !record.separateMap) return false;
if (record === this.activeMapRoot) return true;
this.ensureSubmapContents(record);
this.clearSelection();
this.saveCurrentContextLayout();
if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot);
this.activeMapRoot = record;
this.applyCurrentContextLayout();
if (this.onMapChange) this.onMapChange(record.mapReference, record);
debug("separate concept map opened", { id: record.id, mapReference: record.mapReference });
return true;
}
openRootMap() {
if (!this.activeMapRoot) return false;
this.clearSelection();
this.saveCurrentContextLayout();
this.activeMapRoot = null;
this.mapHistory = [];
this.applyCurrentContextLayout();
if (this.onMapChange) this.onMapChange(null, null);
debug("root concept map opened");
return true;
}
openParentMap() {
if (!this.activeMapRoot) return false;
this.clearSelection();
this.saveCurrentContextLayout();
this.activeMapRoot = this.mapHistory.pop() || null;
this.applyCurrentContextLayout();
const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
debug("parent concept map opened", {
id: this.activeMapRoot ? this.activeMapRoot.id : null,
mapReference: reference
});
return true;
}
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.conceptMaps.set(record.mapReference.id, record.mapReference);
record.expanded = false;
this.updateItem(record, {
synopsis: `Concept map: ${record.childMap}`
});
if (this.onSubMapPromoted) this.onSubMapPromoted(record);
this.refreshSubmapVisibility();
debug("submap promoted to separate map", { id: record.id, childMap: record.childMap });
return record.mapReference;
}
prepareStoredSubmapExtraction(record, targetSlug) {
console.warn(`${debugPrefix} destructive submap extraction is disabled; use a shared derived view`, {
itemId: record ? record.id : null,
targetSlug
});
return null;
/* istanbul ignore next -- retained only to read historical documents */
if (!record || record.kind !== "submap" || !targetSlug) return null;
this.ensureSubmapContents(record);
const sourceDocument = this.toDocument();
const descendants = this.items.filter((item) => this.isDescendantOf(item, record));
const descendantIds = new Set(descendants.map((item) => item.id));
const allItemIds = new Set(this.items.map((item) => item.id));
if (!descendantIds.size) return null;
const descendantDocuments = sourceDocument.items.filter((item) => descendantIds.has(item.id));
const headSource = sourceDocument.items.find((item) => Number(item.id) === Number(record.id));
if (!headSource) return null;
const scopedDocuments = [headSource, ...descendantDocuments];
const left = Math.min(...scopedDocuments.map((item) => Number(item.x) || 0));
const top = Math.min(...scopedDocuments.map((item) => Number(item.y) || 0));
const placeInChild = (item, parentSubmapId, submapDepth) => {
const x = (Number(item.x) || 0) - left + 80;
const y = (Number(item.y) || 0) - top + 80;
const rootLayout = item.layouts && item.layouts.root ? item.layouts.root : {};
return {
...item,
parentSubmapId,
submapDepth,
x,
y,
layouts: {
...(item.layouts || {}),
root: {
...rootLayout,
x,
y,
width: Number(item.width),
height: Number(item.height)
}
}
};
};
const childHead = {
...placeInChild(headSource, null, 0),
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
pageSlug: null,
// The placement in the parent links to this map. The head on the
// child itself must not be a link back to the same map.
cmapSlug: null,
parentCmapLink: false
};
const childItems = [childHead, ...descendantDocuments.map((item) =>
placeInChild(
item,
Number(item.parentSubmapId) === Number(record.id) ? null : item.parentSubmapId,
Math.max(0, Number(item.submapDepth || 0) - record.submapDepth - 1)
))];
const parentConnectors = [];
const childConnectors = [];
const parentConnectorKeys = new Set();
for (const connector of sourceDocument.connectors) {
const sourceInside = descendantIds.has(Number(connector.sourceId));
const targetInside = descendantIds.has(Number(connector.targetId));
const sourceIsHead = Number(connector.sourceId) === Number(record.id);
const targetIsHead = Number(connector.targetId) === Number(record.id);
if ((sourceInside && targetInside) ||
(sourceIsHead && targetInside) ||
(sourceInside && targetIsHead)) {
childConnectors.push({ ...connector });
continue;
}
if (sourceInside || targetInside) {
const outsideId = Number(sourceInside ? connector.targetId : connector.sourceId);
if (!allItemIds.has(outsideId)) {
childConnectors.push({ ...connector });
continue;
}
const rewired = {
...connector,
sourceId: sourceInside ? record.id : connector.sourceId,
targetId: targetInside ? record.id : connector.targetId
};
if (Number(rewired.sourceId) === Number(rewired.targetId)) continue;
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
if (parentConnectorKeys.has(key)) continue;
parentConnectorKeys.add(key);
parentConnectors.push(rewired);
continue;
}
const key = `${connector.sourceId}\u0000${connector.targetId}\u0000${connector.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push({ ...connector });
}
}
const childConceptIds = new Set(childItems.map((item) => item.conceptId).filter(Boolean));
const parentItems = sourceDocument.items
.filter((item) => !descendantIds.has(item.id))
.map((item) => item.id === record.id ? {
...item,
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
cmapSlug: targetSlug,
parentCmapLink: false,
borderColor: "#57834a"
} : item);
const parentConceptIds = new Set(parentItems.map((item) => item.conceptId).filter(Boolean));
const conceptById = new Map(sourceDocument.concepts.map((concept) => [concept.id, concept]));
const childConceptById = new Map(conceptById);
const parentConceptById = new Map(conceptById);
const headDocument = parentItems.find((item) => item.id === record.id);
if (headDocument && headDocument.conceptId) {
parentConceptById.set(headDocument.conceptId, {
id: headDocument.conceptId,
kind: "concept",
label: headDocument.label,
synopsis: headDocument.synopsis,
aspects: headDocument.aspects || [],
descriptionPageSlug: headDocument.descriptionPageSlug,
pageSlug: null,
cmapSlug: targetSlug,
parentCmapLink: false,
imageSource: headDocument.imageSource || ""
});
childConceptById.set(headDocument.conceptId, {
id: headDocument.conceptId,
kind: "concept",
label: childHead.label,
synopsis: childHead.synopsis,
aspects: childHead.aspects || [],
descriptionPageSlug: childHead.descriptionPageSlug,
pageSlug: null,
cmapSlug: null,
parentCmapLink: false,
imageSource: childHead.imageSource || ""
});
}
return {
childDocument: {
schemaVersion: 2,
concepts: Array.from(childConceptById.values()).filter((concept) => childConceptIds.has(concept.id)),
items: childItems,
connectors: childConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
descendantIds.has(Number(reference.rootItemId)))
},
parentDocument: {
...sourceDocument,
concepts: Array.from(parentConceptById.values()).filter((concept) => parentConceptIds.has(concept.id)),
items: parentItems,
connectors: parentConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
Number(reference.rootItemId) !== record.id &&
!descendantIds.has(Number(reference.rootItemId)))
}
};
}
replaceDocument(document) {
if (!document || typeof document !== "object") return false;
this.commitHistory();
const previousSnapshot = this.historySnapshotValue || this.historySnapshot();
this.historyRestoring = true;
try {
this.clearDocument();
this.loadDocument(document);
} finally {
this.historyRestoring = false;
}
const nextSnapshot = this.historySnapshot();
if (previousSnapshot !== nextSnapshot) {
this.undoStack.push(previousSnapshot);
if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
}
this.historySnapshotValue = nextSnapshot;
this.redoStack = [];
this.notifySelection();
this.notifyHistory();
return true;
}
setZoom(percent) {
const next = Math.max(25, Math.min(300, Number(percent) || 100));
this.zoomFactor = next / 100;
this.map.zoom(this.zoomFactor);
this.ensureCanvasExtent(0, 0);
debug("zoom changed", { percent: next, factor: this.zoomFactor });
return next;
}
zoomPercentage() {
return Math.round(this.zoomFactor * 100);
}
surfaceElement() {
return this.canvas.querySelector(":scope > .rw-cmap-surface");
}
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();
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
const identityKeys = ["kind", "label", "synopsis", "aspects", "descriptionPageSlug",
"pageSlug", "cmapSlug",
"parentCmapLink", "imageSource"];
if (record.conceptId && record.kind !== "submap") {
for (const peer of this.items) {
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "submap") 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();
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) {
const a = this.itemCenter(source);
const b = this.itemCenter(target);
const parentSubmap = this.commonSubmapParent([source, target]);
const phrase = this.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.addConnector(source, phrase, false);
this.addConnector(phrase, target, true);
this.reconcilePhraseMembership(phrase);
this.selectItem(phrase);
if (editImmediately) this.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;
}
}
/**
* 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 = {}) {
const sourceCenter = this.itemCenter(source);
const targetCenter = this.itemCenter(target);
const link = this.map.link({
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.nextConnectorId,
link,
source,
target,
visualSource: source,
visualTarget: target,
hasArrow,
lineColor: options.lineColor || "#333",
lineWidth: numberOr(Number(options.lineWidth), 2)
};
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
this.connectors.push(record);
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
link.visible(this.isItemVisible(source) && this.isItemVisible(target));
this.scheduleHistoryCommit();
return record;
}
/**
* goal : Select a concept/linking phrase, optionally beside the current selection.
* pre : record belongs to this editor.
* post : Its logical group is selected as one unit; the primary item exposes handles.
*/
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.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);
if (selected) {
element.setAttribute("aria-selected", "true");
item.node.toFront();
} else {
element.removeAttribute("aria-selected");
}
this.removeHandles(element);
if (selected && item === this.selectedItem) this.ensureHandles(item, element);
}
if (item.kind === "submap") this.updateSubmapFrame(item);
}
}
/**
* goal : Select a connector so its line becomes clearly visible.
* pre : record belongs to this editor.
* post : Previous selection is cleared and the connector is highlighted.
*/
selectConnector(record) {
this.clearSelection();
this.selectedConnector = record;
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
record.link.redraw();
this.notifySelection();
}
/**
* goal : Remove the current item/connector selection.
* post : No item handles or connector highlight remain.
*/
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) {
if (item.submapFrameElement) {
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected");
}
const element = item.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.removeAttribute("aria-selected");
this.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);
}
copySelectionReferences() {
this.copiedConceptIds = Array.from(new Set(this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase" && item.kind !== "submap")
.map((item) => item.conceptId)));
this.notifySelection();
return this.copiedConceptIds.length;
}
canPasteConceptReferences() {
return this.copiedConceptIds.length > 0;
}
pasteConceptReferences() {
const sources = this.copiedConceptIds
.map((conceptId) => this.items.find((item) => item.conceptId === conceptId))
.filter(Boolean);
if (!sources.length) return [];
this.clearSelection(false);
const parentSubmap = this.activeMapRoot || null;
const pasted = sources.map((source, index) => this.addItem({
conceptId: source.conceptId,
kind: source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: source.aspects,
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
parentCmapLink: source.parentCmapLink,
imageSource: source.imageSource,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: 120 + (index * 36),
y: 120 + (index * 36),
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
}));
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.isEffectiveItemVisible(item)) this.selectedItems.add(item);
}
this.selectedItem = this.selectedAll().at(-1) || null;
this.refreshSelectionDecoration();
this.notifySelection();
return this.selectedAll();
}
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.addItem({
...options,
kind: "submap",
label,
childMap: options.childMap || label,
synopsis: options.synopsis || "Grouped sub-concept map.",
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: numberOr(Number(options.x), left),
y: numberOr(Number(options.y), Math.max(20, top - 105)),
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.updateSubmapDepth(item, submap.submapDepth + 1);
}
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.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.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.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
}
if (submap.submapFrameElement) {
submap.submapFrameElement.remove();
submap.submapFrameElement = null;
}
submap.expanded = false;
submap.submapInitialized = false;
submap.childMap = null;
this.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.activeMapRoot) continue;
const parent = item.parentSubmap.parentSubmap;
item.parentSubmap = parent;
this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
affected.push(item);
}
if (!affected.length) return false;
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.refreshSubmapVisibility();
this.refreshSelectionDecoration();
debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
this.notifySelection();
this.scheduleHistoryCommit();
return true;
}
toDocument() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
const concepts = Array.from(new Map(this.items
.filter((record) => record.conceptId && record.kind !== "phrase" && record.kind !== "submap")
.map((record) => [record.conceptId, {
id: record.conceptId,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
parentCmapLink: record.parentCmapLink,
imageSource: record.imageSource
}])).values());
return {
schemaVersion: 2,
concepts,
items: this.items.map((record) => ({
id: record.id,
conceptId: record.conceptId,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
parentCmapLink: record.parentCmapLink,
groupId: record.groupId,
childMap: record.childMap,
parentSubmapId: record.parentSubmap ? record.parentSubmap.id : null,
submapDepth: record.submapDepth,
expanded: record.expanded,
submapInitialized: record.submapInitialized,
separateMap: record.separateMap,
mapReference: record.mapReference,
hiddenContexts: Array.from(record.hiddenContexts),
layouts: record.layouts,
imageSource: record.imageSource,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
submapBackgroundColor: record.submapBackgroundColor,
submapBorderColor: record.submapBorderColor,
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,
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height")),
autoWidth: record.autoWidth,
autoHeight: record.autoHeight,
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y"))
})),
connectors: this.connectors.map((connector) => ({
id: connector.id,
sourceId: connector.source.id,
targetId: connector.target.id,
hasArrow: connector.hasArrow,
lineColor: connector.lineColor,
lineWidth: connector.lineWidth
})).concat(this.unresolvedConnectors.map((connector) => ({ ...connector }))),
conceptMaps: Array.from(this.conceptMaps.values())
};
}
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");
}
const itemDocuments = Array.isArray(document.items) ? document.items : [];
const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : [];
const concepts = new Map((Array.isArray(document.concepts) ? document.concepts : [])
.filter((concept) => concept && concept.id)
.map((concept) => [concept.id, concept]));
const records = new Map();
for (const itemDocument of itemDocuments) {
const concept = concepts.get(itemDocument.conceptId) || {};
const record = this.addItem({ ...itemDocument, ...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.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.unresolvedConnectors.push({ ...connectorDocument });
const unresolvedId = Number(connectorDocument.id);
if (Number.isInteger(unresolvedId) && unresolvedId > 0) {
this.nextConnectorId = Math.max(this.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.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
}
this.conceptMaps = new Map(
(Array.isArray(document.conceptMaps) ? document.conceptMaps : [])
.filter((reference) => reference && reference.id)
.map((reference) => [reference.id, reference]));
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.applyCurrentContextLayout();
this.clearSelection();
if (!this.historyRestoring) this.resetHistory();
return this;
}
/**
* goal : Start editing the currently selected item.
* post : A phrase is edited inline; another item uses the host editor.
* result : True when an item was available for editing.
*/
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;
}
/**
* goal : Start direct editing of a linking phrase.
* pre : record.kind is "phrase".
* post : An input appears in the relation-name node and receives focus.
*/
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.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 = () => {
// Redrawing a phrase can synchronously fit it to its new label. Keep
// that layout work inside this edit transaction without losing the
// snapshot from before the text change.
this.scheduleHistoryCommit();
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.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();
}
/**
* Apply the independently stored heading and synopsis typography.
* Legacy records have already inherited their old shared typography in
* addItem, so merely opening an existing CMap does not restyle it.
*/
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;
}
}
/**
* goal : Redraw selection controls after ionstage/cmap updates a node.
* pre : record.node.redraw() has made a DOM element available.
* post : Selection, drag-to-link and resize interactions are attached.
*/
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) {
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.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.onOpenPage) {
this.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) {
this.openParentMap();
} else if (record.cmapSlug && this.onOpenCmap) {
this.onOpenCmap(record);
} else if (record.pageSlug && this.onOpenPage) {
this.onOpenPage(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.selectedItems.has(record)) {
element.classList.add("rw-cmap-selected");
element.setAttribute("aria-selected", "true");
if (this.selectedItem === record) this.ensureHandles(record, element);
}
this.ensureSubmapToggle(record, element);
debug("cmap node rendered and decorated", {
id: record.id,
kind: record.kind,
selected: this.selectedItems.has(record),
element: elementDescription(element),
style: selectionStyle(element)
});
if (record.editWhenRendered) {
record.editWhenRendered = false;
queueMicrotask(() => this.editPhraseInline(record));
}
this.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.updateSubmapFrame(parent);
parent = parent.parentSubmap;
}
}
}
/**
* goal : Give a new, not manually sized item a compact content-based size.
* pre : element has been rendered and contains the current item HTML.
* post : Automatic dimensions closely surround the text, with long text
* wrapping at a practical maximum width.
*/
fitItemToContent(record, element) {
record.fitContentPending = false;
if (!record.autoWidth && !record.autoHeight) return;
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: "max-content",
height: "auto",
maxWidth: 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: "max-content",
height: "auto",
maxWidth: 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.height;
if (nextWidth === record.width && nextHeight === record.height) return;
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.redrawConnectorsFor(record);
debug("automatic item size applied", {
id: record.id,
kind: record.kind,
width: nextWidth,
height: nextHeight
});
this.refreshHistorySnapshot();
}
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);
}
handleItemMove(record, x, y) {
// A submap node is an anchor placement. Moving the anchor never moves
// the separately positioned expanded group; the frame has its own drag.
const movesCompleteSubmap = false;
const movesSelection = this.selectedItems.has(record) && this.selectedItems.size > 1;
const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection);
if (movesCompleteSubmap || movesSelection) {
this.moveSubmapGroup(record, x, y, moveMembership);
}
return { x, y };
}
beginItemMove(record, includeDescendants = false) {
if (record.moveMembership) return record.moveMembership;
let groupItems = [record];
if (includeDescendants) {
const selected = this.selectedItems.has(record) && this.selectedItems.size > 1 ?
this.selectedAll() : [record];
const expanded = [];
for (const item of selected) {
expanded.push(item);
if (item.kind === "submap" && item !== this.activeMapRoot) {
expanded.push(...this.items.filter((candidate) => this.isDescendantOf(candidate, item)));
}
}
groupItems = Array.from(new Set(expanded));
}
record.moveMembership = {
parent: record.parentSubmap,
parentBounds: record.parentSubmap ? this.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.updateSubmapFrame(submap);
}
}
handleItemMoveEnd(record) {
const moveMembership = record.moveMembership;
record.moveMembership = null;
if (!moveMembership) return;
const movedItems = this.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.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.pointInBounds(center, moveMembership.parentBounds)) {
parent = previousParent;
} else {
parent = this.submapAtPoint(center, null, movedItems);
}
if (!parent && this.activeMapRoot && !movedItems.includes(this.activeMapRoot)) {
parent = this.activeMapRoot;
}
if (previousParent && parent !== previousParent && this.onConfirmDetachFromSubmap &&
!this.onConfirmDetachFromSubmap(record, previousParent, parent)) {
parent = previousParent;
}
if (parent !== previousParent) {
for (const item of movedItems) {
if (item === this.activeMapRoot) continue;
item.parentSubmap = parent;
this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
}
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
debug("selection submap membership changed", {
itemIds: movedItems.map((item) => item.id),
previousParentId: previousParent ? previousParent.id : null,
parentId: parent ? parent.id : null
});
}
this.refreshSubmapVisibility();
this.scheduleHistoryCommit();
return;
}
if (record.kind === "phrase") {
this.scheduleHistoryCommit();
return;
}
// A separately opened map keeps its head linked to the parent map. Moving
// that head edits its position inside the current view; it must not be
// interpreted as dragging the complete map out of its parent submap.
if (record === this.activeMapRoot) {
this.refreshSubmapVisibility();
debug("active map head moved without changing parent membership", {
id: record.id,
parentId: record.parentSubmap ? record.parentSubmap.id : null
});
this.scheduleHistoryCommit();
return;
}
const center = this.itemCenter(record);
let parent = null;
if (moveMembership.parent && this.pointInBounds(center, moveMembership.parentBounds)) {
parent = moveMembership.parent;
} else {
parent = this.submapAtPoint(center, record);
}
if (!parent && this.activeMapRoot && record !== this.activeMapRoot) parent = this.activeMapRoot;
if (moveMembership.parent && parent !== moveMembership.parent &&
this.onConfirmDetachFromSubmap &&
!this.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) {
parent = moveMembership.parent;
}
if (parent !== record.parentSubmap) {
const previousParent = record.parentSubmap;
record.parentSubmap = parent;
this.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.reconcilePhraseMembership();
this.refreshConceptMapReferences();
}
this.refreshSubmapVisibility();
this.scheduleHistoryCommit();
}
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 segmentLengthSquared = (segmentX * segmentX) + (segmentY * segmentY);
if (segmentLengthSquared === 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)) /
segmentLengthSquared));
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.connectorEndpoint(connector.source);
const target = connector.visualTarget || this.connectorEndpoint(connector.target);
if (!source || !target || source === target) return false;
return 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.isDescendantOf(item, excludedRecord)) &&
!excludedRecords.some((record) => this.isDescendantOf(item, record)) &&
this.isItemVisible(item))
.sort((a, b) => b.submapDepth - a.submapDepth);
const match = candidates.find((item) => this.pointInBounds(point, this.submapBounds(item, excludedRecord)));
return match || (this.activeMapRoot && this.activeMapRoot !== excludedRecord &&
!exclusions.has(this.activeMapRoot) ? this.activeMapRoot : null);
}
submapBounds(record, excludedRecord = null) {
const visibleItems = this.items.filter((item) =>
this.isDescendantOf(item, record) &&
item !== excludedRecord &&
(!excludedRecord || !this.isDescendantOf(item, excludedRecord)) &&
this.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.isItemVisible(record)) return record;
if (record.kind === "phrase" || this.activeMapRoot) return null;
let parent = record.parentSubmap;
while (parent) {
if (this.isItemVisible(parent)) return parent;
parent = parent.parentSubmap;
}
return null;
}
isEffectiveItemVisible(record) {
if (!this.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) {
if (record.kind === "phrase") {
return this.isEffectiveItemVisible(record) ? record : null;
}
return 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();
}
refreshSubmapVisibility() {
for (const item of this.items) item.node.visible(this.isEffectiveItemVisible(item));
for (const connector of this.connectors) {
this.applyConnectorVisualEndpoints(connector,
this.connectorEndpoint(connector.source),
this.connectorEndpoint(connector.target));
}
const submaps = this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth);
for (const submap of submaps) {
this.updateSubmapFrame(submap);
const element = submap.node.element();
if (element) this.ensureSubmapToggle(submap, element);
}
}
updateSubmapFrame(record) {
const surface = this.surfaceElement();
const shouldShow = record !== this.activeMapRoot &&
record.expanded && this.isItemVisible(record);
if (!shouldShow || !surface) {
if (record.submapFrameElement) {
record.submapFrameElement.remove();
record.submapFrameElement = null;
}
if (record.submapAnchorLineElement) {
record.submapAnchorLineElement.remove();
record.submapAnchorLineElement = null;
}
return;
}
if (!record.submapFrameElement) {
const frame = document.createElement("div");
frame.className = "rw-cmap-submap-frame";
frame.dataset.rwCmapSubmapId = String(record.id);
frame.tabIndex = 0;
frame.setAttribute("role", "group");
frame.setAttribute("aria-label", record.label);
frame.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
if (this.pointNearConnector(this.canvasPoint(event))) return;
this.startSubmapFrameDrag(event, record);
});
frame.addEventListener("dblclick", (event) => {
event.preventDefault();
event.stopPropagation();
this.selectItem(record);
if (this.onEditItem) this.onEditItem(record);
});
const collapse = document.createElement("button");
collapse.type = "button";
collapse.className = "rw-cmap-submap-frame-toggle";
collapse.textContent = "«";
collapse.title = "Collapse submap";
collapse.setAttribute("aria-label", collapse.title);
collapse.setAttribute("aria-expanded", "true");
collapse.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
collapse.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.selectItem(record);
this.toggleSubmap(record, false);
});
frame.append(collapse);
surface.prepend(frame);
record.submapFrameElement = frame;
}
const bounds = this.submapBounds(record);
if (!bounds) return;
const { left, top, right, bottom } = bounds;
Object.assign(record.submapFrameElement.style, {
left: `${left}px`,
top: `${top}px`,
width: `${right - left}px`,
height: `${bottom - top}px`
});
record.submapFrameElement.style.setProperty(
"--rw-cmap-submap-background", record.submapBackgroundColor || "#edf7e8");
record.submapFrameElement.style.setProperty(
"--rw-cmap-submap-border", record.submapBorderColor || "#57834a");
record.submapFrameElement.classList.toggle(
"rw-cmap-submap-frame-selected", this.selectedItems.has(record));
record.submapFrameElement.setAttribute("aria-label", record.label);
this.updateSubmapAnchorLine(record, bounds, surface);
this.ensureCanvasExtent(right, bottom);
}
updateSubmapAnchorLine(record, bounds, surface = this.surfaceElement()) {
if (!surface || !bounds || record === this.activeMapRoot || !record.expanded) 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.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}`);
}
startSubmapFrameDrag(event, record) {
event.preventDefault();
event.stopPropagation();
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(record)) {
this.selectedItem = record;
this.refreshSelectionDecoration();
this.notifySelection();
} else {
this.selectItem(record, { additive });
}
const pointerId = event.pointerId;
const startClientX = event.clientX;
const startClientY = event.clientY;
const bounds = this.submapBounds(record);
if (!bounds) return;
const moveMembership = {
parent: record.parentSubmap,
parentBounds: null,
startX: bounds.left,
startY: bounds.top,
groupPositions: this.items
.filter((item) => this.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.saveCurrentContextLayout();
this.refreshSubmapVisibility();
this.scheduleHistoryCommit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
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();
}
}
}
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.canvasPoint(event);
if (this.pointNearConnector(start)) return;
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
const surface = this.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.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.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.clearSelection();
return;
}
if (!additive) this.clearSelection(false);
const matches = this.items.filter((item) => {
if (!this.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.isItemVisible(candidate))) expanded.add(member);
}
for (const item of expanded) this.selectedItems.add(item);
this.selectedItem = matches.at(-1) || this.selectedItem;
this.selectedConnector = null;
this.refreshSelectionDecoration();
this.notifySelection();
debug("marquee selection applied", {
selectedIds: this.selectedAll().map((item) => item.id)
});
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
this.activeMarqueeCleanup = cleanup;
};
this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler);
}
destroy() {
this.destroyed = true;
if (this.historyTimer !== null) {
window.clearTimeout(this.historyTimer);
this.historyTimer = null;
}
if (this.marqueeMouseDownHandler) {
this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler);
this.marqueeMouseDownHandler = null;
}
if (this.boundaryScrollHandler) {
this.canvas.removeEventListener("scroll", this.boundaryScrollHandler);
this.boundaryScrollHandler = null;
}
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
for (const item of this.items) {
if (item.submapFrameElement) {
item.submapFrameElement.remove();
item.submapFrameElement = null;
}
if (item.submapAnchorLineElement) {
item.submapAnchorLineElement.remove();
item.submapAnchorLineElement = null;
}
}
if (this.map && typeof this.map.destroy === "function") this.map.destroy();
debug("editor destroyed");
}
deleteSelection() {
const records = new Set(this.selectedAll().filter((item) => item !== this.activeMapRoot));
for (const record of Array.from(records)) {
if (record.kind === "submap") {
for (const item of this.items) {
if (this.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.connectors = this.connectors.filter((connector) => !connectors.has(connector));
for (const record of records) {
if (record.submapFrameElement) record.submapFrameElement.remove();
if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id);
record.node.remove();
}
if (records.size && this.unresolvedConnectors.length) {
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
this.unresolvedConnectors = this.unresolvedConnectors.filter((connector) =>
!deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
}
this.items = this.items.filter((item) => !records.has(item));
this.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.refreshSubmapVisibility();
this.notifySelection();
debug("selection deleted", {
itemIds: Array.from(records).map((item) => item.id),
connectorIds: Array.from(connectors).map((connector) => connector.id)
});
this.scheduleHistoryCommit();
return true;
}
/**
* goal : Follow the component selected by cmap's coordinate hit test.
* pre : component is a public node/link wrapper or null.
* post : The corresponding wiki item or connector is selected; empty
* canvas space clears the selection.
*/
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.selectedItems.size > 1 && this.selectedItems.has(item)) {
this.selectedItem = item;
this.refreshSelectionDecoration();
this.notifySelection();
return;
}
this.selectItem(item, { additive, toggle: additive });
return;
}
if (connector) {
this.selectConnector(connector);
return;
}
if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.clearSelection();
}
/**
* goal : Activate an item after cmap recognizes two stationary clicks.
* pre : component is the public wrapper returned by cmap's hit test.
* post : Page concepts navigate, submaps open and phrases enter editing.
*/
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.editPhraseInline(item);
return;
}
if (item.kind === "submap") {
this.toggleSubmap(item);
return;
}
if (this.onEditItem) {
this.selectItem(item);
this.onEditItem(item);
return;
}
if (item.parentCmapLink) {
this.openParentMap();
return;
}
if (item.cmapSlug && this.onOpenCmap) {
this.onOpenCmap(item);
return;
}
if (item.pageSlug && this.onOpenPage) {
this.onOpenPage(item);
return;
}
}
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.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.selectItem(record);
this.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.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.selectItem(record);
this.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.labels.createRelation;
relation.setAttribute("aria-label", this.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.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.labels.editConcept;
edit.setAttribute("aria-label", this.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.selectItem(record);
if (this.onEditItem) this.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.labels.resizeConcept;
resize.setAttribute("aria-label", this.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
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.decorateItem(record);
this.redrawConnectorsFor(record);
this.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.decorateItem(record);
this.scheduleHistoryCommit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
startRelationDrag(event, source) {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const start = this.itemCenter(source);
const draft = this.createDraftLine(start);
this.dragRelation = { source, draft };
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const point = this.canvasPoint(moveEvent);
this.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.logicalCanvasWidth(), point.x + 180)));
draft.svg.setAttribute("height", String(Math.max(this.logicalCanvasHeight(), point.y + 180)));
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
const target = this.itemAt(upEvent.clientX, upEvent.clientY);
const point = this.canvasPoint(upEvent);
draft.svg.remove();
this.dragRelation = null;
if (!target) {
this.ensureCanvasExtent(point.x, point.y);
const parentSubmap = this.submapAtPoint(point);
debug("relation dropped on empty canvas", {
sourceId: source.id,
point,
parentSubmapId: parentSubmap ? parentSubmap.id : null
});
if (this.onCreateConnectedItem) this.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.addConnector(source, target, true);
this.reconcilePhraseMembership(source);
this.refreshSubmapVisibility();
this.selectItem(source);
return;
}
if (source.kind !== "phrase" && target.kind === "phrase") {
this.addConnector(source, target, false);
this.reconcilePhraseMembership(target);
this.refreshSubmapVisibility();
this.selectItem(target);
return;
}
if (source.kind === "phrase" && target.kind === "phrase") return;
if (direct) {
const connector = this.addConnector(source, target, true);
this.refreshSubmapVisibility();
this.selectConnector(connector);
return;
}
this.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.logicalCanvasWidth()));
svg.setAttribute("height", String(this.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.surfaceElement() || this.canvas).append(svg);
return { svg, line };
}
logicalCanvasWidth() {
const surface = this.surfaceElement();
return Math.max(this.canvas.clientWidth / this.zoomFactor,
surface ? surface.scrollWidth : 0);
}
logicalCanvasHeight() {
const surface = this.surfaceElement();
return Math.max(this.canvas.clientHeight / this.zoomFactor,
surface ? surface.scrollHeight : 0);
}
canvasPoint(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left + this.canvas.scrollLeft) / this.zoomFactor,
y: (event.clientY - rect.top + this.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)
};
}
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.renderItem) return this.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
notifySelection() {
if (this.onSelectionChange) {
this.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
}
}
}
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.99",
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;
}
};
})();