Files
racket-wiki/static/cmap/cmap-racket-wiki.js
T

2111 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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.84]";
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;
}
//////////////////////////////////////////////////////////////////////////////
// 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.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.conceptMaps = new Map();
this.activeMapRoot = null;
this.mapHistory = [];
this.selectedItem = null;
this.selectedItems = new Set();
this.selectedConnector = null;
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.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();
record.node.remove();
}
this.items = [];
this.connectors = [];
this.conceptMaps = new Map();
this.activeMapRoot = null;
this.mapHistory = [];
this.nextId = 1;
this.nextConnectorId = 1;
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.refreshSubmapVisibility();
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,
kind,
label: options.label || "Concept",
synopsis: options.synopsis || "",
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,
submapFrameElement: 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",
width: options.width || (kind === "phrase" ? 145 : 220),
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
autoWidth,
autoHeight,
fitContentPending: autoWidth || autoHeight,
node: 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.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;
}
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);
}
}
isDescendantOf(record, submap) {
let parent = record.parentSubmap;
while (parent) {
if (parent === submap) return true;
parent = parent.parentSubmap;
}
return false;
}
isItemVisible(record) {
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;
}
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;
if (record.separateMap) {
record.expanded = false;
if (record === this.activeMapRoot) {
this.refreshSubmapVisibility();
return false;
}
return this.openSubmapMap(record);
}
if (expanded) this.ensureSubmapContents(record);
record.expanded = Boolean(expanded);
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();
if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot);
this.activeMapRoot = record;
this.refreshSubmapVisibility();
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.activeMapRoot = null;
this.mapHistory = [];
this.refreshSubmapVisibility();
if (this.onMapChange) this.onMapChange(null, null);
debug("root concept map opened");
return true;
}
openParentMap() {
if (!this.activeMapRoot) return false;
this.clearSelection();
this.activeMapRoot = this.mapHistory.pop() || null;
this.refreshSubmapVisibility();
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;
}
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;
}
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);
}
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.refreshSubmapVisibility();
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.refreshConceptMapReferences();
return {
schemaVersion: 1,
items: this.items.map((record) => ({
id: record.id,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
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,
imageSource: record.imageSource,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
submapBackgroundColor: record.submapBackgroundColor,
submapBorderColor: record.submapBorderColor,
textColor: record.textColor,
fontFamily: record.fontFamily,
fontSize: record.fontSize,
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
})),
conceptMaps: Array.from(this.conceptMaps.values())
};
}
loadDocument(document = {}) {
if (this.items.length || this.connectors.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 records = new Map();
for (const itemDocument of itemDocuments) {
const record = this.addItem({ ...itemDocument, 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) 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.refreshSubmapVisibility();
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();
}
/**
* 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.overflow = "visible";
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 });
}
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,
lineHeight: "1.25",
overflow: "visible",
pointerEvents: "none",
transform: "none",
visibility: "hidden",
whiteSpace: "normal"
});
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) {
const movesCompleteSubmap = record.kind === "submap" &&
!record.expanded && record !== this.activeMapRoot;
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.expanded && 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) =>
(item === record || 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) {
if (item.kind === "submap" && item.separateMap) item.expanded = false;
}
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.separateMap && record !== this.activeMapRoot &&
record.expanded && this.isItemVisible(record);
if (!shouldShow || !surface) {
if (record.submapFrameElement) {
record.submapFrameElement.remove();
record.submapFrameElement = 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.ensureCanvasExtent(right, bottom);
}
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 moveMembership = this.beginItemMove(record, true);
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);
this.handleItemMoveEnd(record);
};
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() {
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.activeMarqueeCleanup) this.activeMarqueeCleanup();
for (const item of this.items) {
if (item.submapFrameElement) {
item.submapFrameElement.remove();
item.submapFrameElement = null;
}
}
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)));
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 this.items.filter((item) => item.kind === "phrase" && !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) connectors.add(connector);
}
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();
}
this.items = this.items.filter((item) => !records.has(item));
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 (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");
if (record.kind !== "submap") {
if (toggle) toggle.remove();
return;
}
if (record === this.activeMapRoot || (!record.separateMap && record.expanded)) {
if (toggle) toggle.remove();
return;
}
if (!toggle) {
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);
}
toggle.textContent = record.separateMap ? "↗" : (record.expanded ? "" : "+");
toggle.title = record.separateMap ? "Open concept map" :
(record.expanded ? "Collapse submap" : "Expand submap");
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(record.expanded));
}
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);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
finishRelation(source, target) {
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;
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.84",
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;
}
};
})();