2111 lines
80 KiB
JavaScript
2111 lines
80 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.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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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;
|
||
}
|
||
};
|
||
})();
|