big cmap refactoring

This commit is contained in:
2026-09-03 08:40:15 +02:00
parent f0562a06cc
commit 2c141b6d3f
32 changed files with 5616 additions and 4216 deletions
+27 -7
View File
@@ -2,12 +2,32 @@
This directory contains the complete browser-side CMap component.
`cmap.js` is the drawing and hit-testing engine. It is based on the
MIT-licensed ionstage/cmap 0.1.3 source and is now maintained as part of
racket-wiki. Its public additions include `onSelection` on a map and
`onActivation` and `onRendered` callbacks. Activation is recognized inside the
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`dblclick` event that may be suppressed by dragging.
`cmap.js` is the ES-module entry point of the diagram engine. Its public
`DiagramEngine`, `DiagramNode` and `DiagramLink` classes expose only the API
used by the wiki editor: creating nodes and links, changing their presentation,
connecting endpoints, zooming and receiving render, selection, activation and
move events. The engine does not install a `window.Cmap` global.
The modules under `engine/` separate that API from the renderer internals.
`diagram-engine.js` owns the surface and component lifetime;
`diagram-node.js`, `diagram-link.js` and `diagram-component.js` define the
public handles. The `drawing-*` modules contain DOM rendering, geometry,
relations, hit testing and pointer interaction. That rendering core is based on
the MIT-licensed ionstage/cmap 0.1.3 source and is maintained as part of
racket-wiki. Activation is recognized inside the same hit-test and drag
lifecycle as selection, so it does not depend on a DOM `dblclick` event that
may be suppressed by dragging.
`diagram-group.js` adds the generic view-level `DiagramGroup` abstraction. A
group contains nodes or nested groups, calculates a frame from their geometry,
and can be expanded or collapsed. It deliberately knows nothing about CMaps,
wiki pages or aspects. `DiagramEngine.setFilter` accepts an application policy
for component visibility; the engine combines that policy with each component's
own visibility and hides links whose endpoints are hidden. A wiki adapter can
therefore translate submap membership or aspect matching into groups and
filters without putting domain rules into the drawing engine. Proxy endpoints
and boundary navigation remain application concerns until a generic endpoint
projection API is introduced.
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
@@ -25,7 +45,7 @@ The repository objects keep only an in-memory session copy; the backend remains
authoritative. None of these model modules contains DOM or drawing-engine
objects, and CMap state is not persisted in browser storage.
`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance.
`cmap-view.js` owns the canvas and its concrete `DiagramEngine` instance.
`view/appearance-editor.js` presents the appearance model in the concept dialog
and coordinates explicit style and palette changes with its repository.
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
"use strict";
export const debugPrefix = "[racket-wiki:cmap 0.2.122]";
export function debug(message, details) {
if (details === undefined) {
console.info(debugPrefix, message);
return;
}
console.info(debugPrefix, message, details);
}
export function elementDescription(element) {
if (!(element instanceof Element)) return String(element);
return {
tag: element.tagName,
id: element.id || null,
classes: Array.from(element.classList),
itemId: element.dataset.rwCmapItemId || null
};
}
export function selectionStyle(element) {
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
const style = window.getComputedStyle(element);
return {
pointerEvents: style.pointerEvents,
outline: style.outline,
outlineOffset: style.outlineOffset,
boxShadow: style.boxShadow,
overflow: style.overflow,
zIndex: style.zIndex
};
}
export function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
export function conceptDescriptionReference(label, id) {
const slug = String(label || "")
.normalize("NFKD")
.toLocaleLowerCase()
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120)
.replace(/-+$/g, "");
return `cmap:${slug || `concept-${id}`}`;
}
export function newConceptId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
try {
return window.crypto.randomUUID().toLowerCase();
} catch (_error) {
// randomUUID can be exposed but forbidden in an insecure/file context.
}
}
const bytes = new Uint8Array(16);
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
window.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
export function normalizeConceptTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
return {
type: String(tag.type || "label").trim() || "label",
value: String(tag.value || tag.name || "").trim()
};
}).filter((tag) => tag && tag.value);
}
+7 -3
View File
@@ -1,3 +1,5 @@
import { DiagramEngine } from "./cmap.js";
/**
* Present a CMap through the bundled drawing engine.
*
@@ -6,11 +8,13 @@
* rules; CmapEditor remains responsible for interpreting user interaction.
*/
export class CmapView {
constructor(canvas, CmapFactory, onSelection, onActivation) {
constructor(canvas, onSelection, onActivation, createEngine = null) {
if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required");
if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required");
if (createEngine !== null && typeof createEngine !== "function") {
throw new TypeError("A diagram-engine factory must be a function");
}
this.canvas = canvas;
this.map = CmapFactory(canvas);
this.map = createEngine ? createEngine(canvas) : new DiagramEngine(canvas);
this.map.onSelection(onSelection);
this.map.onActivation(onActivation);
}
+46 -3
View File
@@ -362,11 +362,12 @@ body.cmap-mode #main {
--cmap-a4-width: 1123px;
--cmap-a4-height: 794px;
position: relative;
flex: 1 0 auto;
z-index: 1;
flex: 1 1 0;
width: max-content;
min-width: max(760px, calc(100% - 90px));
min-height: 794px;
overflow: visible;
overflow: auto;
border: 0;
background-color: #fff;
background-image:
@@ -376,6 +377,12 @@ body.cmap-mode #main {
box-shadow: inset 1px 1px #d9dde2;
}
/* Keep the editor viewport bounded so vertical canvas scrolling is available. */
.cmap-workspace > .cmap-canvas {
height: calc(100vh - 180px);
min-height: 0;
}
.cmap-canvas.cmap-page-guides-hidden {
background-image: none;
box-shadow: none;
@@ -388,6 +395,15 @@ body.cmap-mode #main {
transform-origin: 0 0;
}
.cmap-canvas:has(.rw-cmap-surface) {
cursor: grab;
}
.cmap-canvas.rw-cmap-canvas-panning {
cursor: grabbing;
user-select: none;
}
.rw-cmap-embed {
display: block;
margin: 1.25rem 0;
@@ -773,7 +789,7 @@ body.cmap-mode #main {
);
box-shadow: 0 1px 3px rgb(35 55 30 / 18%);
cursor: move;
pointer-events: auto;
pointer-events: none;
user-select: none;
}
@@ -822,6 +838,33 @@ body.cmap-mode #main {
pointer-events: auto;
}
.rw-cmap-submap-frame-drag-edge {
position: absolute;
z-index: 2;
cursor: move;
pointer-events: auto;
}
.rw-cmap-submap-frame-drag-edge-top,
.rw-cmap-submap-frame-drag-edge-bottom {
right: 0;
left: 0;
height: 10px;
}
.rw-cmap-submap-frame-drag-edge-top { top: 0; }
.rw-cmap-submap-frame-drag-edge-bottom { bottom: 0; }
.rw-cmap-submap-frame-drag-edge-right,
.rw-cmap-submap-frame-drag-edge-left {
top: 0;
bottom: 0;
width: 10px;
}
.rw-cmap-submap-frame-drag-edge-right { right: 0; }
.rw-cmap-submap-frame-drag-edge-left { left: 0; }
.rw-cmap-boundary-layer,
.rw-cmap-boundary-lines {
position: absolute;
+7 -2303
View File
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
/**
* Undo/redo history for an editor whose state can be represented as JSON.
*
* The history owns snapshots, stack limits and the asynchronous commit boundary.
* It does not know how a document is rendered or restored: those concerns are
* supplied by the editor through the constructor callbacks.
*/
export class CmapHistory {
/**
* goal : Create a history coordinator for one editor state.
* pre : snapshot and restore are functions for the same serialized state.
* post : The coordinator is ready to track state after reset() is called.
* result : A CmapHistory instance with empty undo and redo stacks.
* internals : The callbacks keep this controller independent of the editor's
* model and view; the stacks contain only serialized snapshots.
*
* @param {object} options History callbacks and configuration.
* @param {Function} options.snapshot Returns the current serialized state.
* @param {Function} options.restore Restores one serialized state.
* @param {Function} [options.onChange] Receives undo/redo availability changes.
* @param {number} [options.limit=100] Maximum number of undo snapshots.
*/
constructor({ snapshot, restore, onChange = null, limit = 100 }) {
if (typeof snapshot !== "function") throw new TypeError("A snapshot function is required");
if (typeof restore !== "function") throw new TypeError("A restore function is required");
this.snapshot = snapshot;
this.restoreDocument = restore;
this.onChange = onChange;
this.limit = Math.max(1, Number(limit) || 100);
this.undoStack = [];
this.redoStack = [];
this.currentSnapshot = null;
this.timer = null;
this.ready = false;
this.isRestoring = false;
}
/** Notify the host about the current availability of undo and redo. */
notify() {
if (this.onChange) {
this.onChange({
canUndo: this.canUndo(),
canRedo: this.canRedo()
});
}
}
/**
* goal : Start a new history session at the current editor state.
* pre : The snapshot callback returns the current serialized state.
* post : Both stacks are empty and the current state is the history baseline.
* result : Undefined; the host is notified of the empty stacks.
* internals : A pending timer is cancelled before the baseline is captured.
*/
reset() {
this.cancelScheduledCommit();
this.undoStack = [];
this.redoStack = [];
this.ready = true;
this.currentSnapshot = this.snapshot();
this.notify();
}
/** Cancel a pending asynchronous history commit. */
cancelScheduledCommit() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
this.timer = null;
}
}
/**
* Schedule one commit for the current mutation transaction.
* The zero-delay timer groups synchronous editor changes into one undo step.
*/
scheduleCommit() {
if (!this.ready || this.isRestoring) return;
this.cancelScheduledCommit();
this.timer = window.setTimeout(() => {
this.timer = null;
this.commit();
}, 0);
}
/** Update the baseline after renderer-only normalization. */
refreshSnapshot() {
if (!this.ready || this.isRestoring || this.timer !== null) return;
this.currentSnapshot = this.snapshot();
}
/**
* Commit the current state when it differs from the baseline.
* @returns {boolean} Whether a new undo step was recorded.
*/
commit() {
if (!this.ready || this.isRestoring) return false;
this.cancelScheduledCommit();
const nextSnapshot = this.snapshot();
if (nextSnapshot === this.currentSnapshot) return false;
if (this.currentSnapshot !== null) {
this.undoStack.push(this.currentSnapshot);
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
this.currentSnapshot = nextSnapshot;
this.redoStack = [];
this.notify();
return true;
}
/** Return whether an undo operation is available. */
canUndo() {
return this.undoStack.length > 0;
}
/** Return whether a redo operation is available. */
canRedo() {
return this.redoStack.length > 0;
}
get undoCount() {
return this.undoStack.length;
}
get redoCount() {
return this.redoStack.length;
}
/**
* Restore one snapshot while suppressing history commits caused by loading.
* The editor callback performs the actual model and view reconstruction.
*/
restoreSnapshot(snapshot) {
this.isRestoring = true;
try {
this.restoreDocument(snapshot);
} finally {
this.isRestoring = false;
}
this.currentSnapshot = snapshot;
this.notify();
}
/** Restore the previous committed state, if one exists. */
undo() {
this.commit();
if (!this.canUndo()) return false;
this.redoStack.push(this.currentSnapshot);
const snapshot = this.undoStack.pop();
this.restoreSnapshot(snapshot);
return true;
}
/** Restore the most recently undone state, if one exists. */
redo() {
this.commit();
if (!this.canRedo()) return false;
this.undoStack.push(this.currentSnapshot);
const snapshot = this.redoStack.pop();
this.restoreSnapshot(snapshot);
return true;
}
/**
* goal : Replace the current document as one undoable operation.
* pre : replaceDocument performs the complete document replacement.
* post : The replacement is current and redo history has been discarded.
* result : Undefined; the host receives the new undo/redo availability.
* internals : The old baseline is pushed before the callback runs, while
* isRestoring prevents loading callbacks from creating nested history steps.
*/
replace(replaceDocument) {
this.commit();
const previousSnapshot = this.currentSnapshot || this.snapshot();
this.isRestoring = true;
try {
replaceDocument();
} finally {
this.isRestoring = false;
}
const nextSnapshot = this.snapshot();
if (previousSnapshot !== nextSnapshot) {
this.undoStack.push(previousSnapshot);
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
this.currentSnapshot = nextSnapshot;
this.redoStack = [];
this.notify();
}
/** Release the timer when the owning editor is destroyed. */
destroy() {
this.cancelScheduledCommit();
}
}
@@ -0,0 +1,568 @@
"use strict";
import { debug, elementDescription } from "../cmap-utils.js";
export class CmapInteractionController {
constructor(editor) {
this.editor = editor;
this.marqueeMouseDownHandler = null;
this.activeMarqueeCleanup = null;
this.canvasPanPointerDownHandler = null;
this.activeCanvasPanCleanup = null;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get canvas() { return this.editor.canvas; }
get zoomFactor() { return this.editor.zoomFactor; }
handleItemMove(record, x, y) {
const movesCompleteSubmap = false;
const movesSelection = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1;
const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection);
if (moveMembership.groupPositions.length > 1) {
this.moveSubmapGroup(record, x, y, moveMembership);
queueMicrotask(() => this.redrawAllConnectors());
}
return { x, y };
}
beginItemMove(record, includeDescendants = false) {
if (record.moveMembership) return record.moveMembership;
let groupItems = [record];
if (includeDescendants) {
const selected = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1 ?
this.editor.selectedAll() : [record];
const expanded = [];
for (const item of selected) {
expanded.push(item);
if (item.kind === "submap" && item !== this.editor.activeMapRoot) {
expanded.push(...this.items.filter((candidate) => this.editor.isDescendantOf(candidate, item)));
}
}
groupItems = Array.from(new Set(expanded));
}
groupItems = this.includeLinkedMapItems(groupItems);
record.moveMembership = {
parent: record.parentSubmap,
parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null,
startX: Number(record.node.attr("x")),
startY: Number(record.node.attr("y")),
groupPositions: groupItems.map((item) => ({
item,
x: Number(item.node.attr("x")),
y: Number(item.node.attr("y"))
}))
};
return record.moveMembership;
}
includeLinkedMapItems(groupItems) {
const group = new Set(groupItems);
const pending = [...groupItems];
while (pending.length) {
const current = pending.pop();
for (const connector of this.connectors) {
if (connector.source !== current && connector.target !== current) continue;
const other = connector.source === current ? connector.target : connector.source;
const include = other.kind === "phrase" || Boolean(other.cmapSlug);
if (!include || group.has(other)) continue;
group.add(other);
pending.push(other);
}
}
return Array.from(group);
}
moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
const deltaX = x - moveMembership.startX;
const deltaY = y - moveMembership.startY;
for (const position of moveMembership.groupPositions) {
position.item.node.attr({
x: position.x + deltaX,
y: position.y + deltaY
});
position.item.node.redraw();
}
for (const connector of this.connectors) {
connector.link.straighten();
connector.link.redraw();
}
for (const submap of this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth)) {
this.editor.updateSubmapFrame(submap);
}
}
redrawAllConnectors() {
for (const connector of this.connectors) {
connector.link.straighten();
connector.link.redraw();
}
}
handleItemMoveEnd(record) {
const moveMembership = record.moveMembership;
record.moveMembership = null;
if (!moveMembership) return;
this.redrawAllConnectors();
const movedItems = this.editor.selectedAll().filter((item) =>
moveMembership.groupPositions.some((position) => position.item === item));
const movedParents = new Set(movedItems.map((item) => item.parentSubmap));
if (movedItems.length > 1 && movedParents.size === 1) {
const previousParent = movedItems[0].parentSubmap;
const centers = movedItems.map((item) => this.editor.itemCenter(item));
const center = {
x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length,
y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length
};
let parent = null;
if (previousParent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
parent = previousParent;
} else {
parent = this.editor.submapAtPoint(center, null, movedItems);
}
if (!parent && this.editor.activeMapRoot && !movedItems.includes(this.editor.activeMapRoot)) {
parent = this.editor.activeMapRoot;
}
if (previousParent && parent !== previousParent && this.editor.onConfirmDetachFromSubmap &&
!this.editor.onConfirmDetachFromSubmap(record, previousParent, parent)) {
parent = previousParent;
}
if (parent !== previousParent) {
for (const item of movedItems) {
if (item === this.editor.activeMapRoot) continue;
item.parentSubmap = parent;
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
}
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
debug("selection submap membership changed", {
itemIds: movedItems.map((item) => item.id),
previousParentId: previousParent ? previousParent.id : null,
parentId: parent ? parent.id : null
});
}
this.editor.refreshSubmapVisibility();
this.editor.scheduleHistoryCommit();
return;
}
if (record.kind === "phrase") {
this.editor.scheduleHistoryCommit();
return;
}
if (record === this.editor.activeMapRoot) {
this.editor.refreshSubmapVisibility();
debug("active map head moved without changing parent membership", {
id: record.id,
parentId: record.parentSubmap ? record.parentSubmap.id : null
});
this.editor.scheduleHistoryCommit();
return;
}
const center = this.editor.itemCenter(record);
let parent = null;
if (moveMembership.parent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
parent = moveMembership.parent;
} else {
parent = this.editor.submapAtPoint(center, record);
}
if (!parent && this.editor.activeMapRoot && record !== this.editor.activeMapRoot) parent = this.editor.activeMapRoot;
if (moveMembership.parent && parent !== moveMembership.parent &&
this.editor.onConfirmDetachFromSubmap &&
!this.editor.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) {
parent = moveMembership.parent;
}
if (parent !== record.parentSubmap) {
const previousParent = record.parentSubmap;
record.parentSubmap = parent;
this.editor.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0);
debug("item submap membership changed", {
id: record.id,
previousParentId: previousParent ? previousParent.id : null,
parentId: parent ? parent.id : null
});
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
}
this.editor.refreshSubmapVisibility();
this.editor.scheduleHistoryCommit();
}
startSubmapFrameDrag(event, record) {
event.preventDefault();
event.stopPropagation();
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(record)) {
this.editor.selectedItem = record;
this.editor.refreshSelectionDecoration();
this.editor.notifySelection();
} else {
this.editor.selectItem(record, { additive });
}
const pointerId = event.pointerId;
const startClientX = event.clientX;
const startClientY = event.clientY;
const bounds = this.editor.submapBounds(record);
if (!bounds) return;
const moveMembership = {
parent: record.parentSubmap,
parentBounds: null,
startX: bounds.left,
startY: bounds.top,
groupPositions: this.items
.filter((item) => this.editor.isDescendantOf(item, record))
.map((item) => ({
item,
x: Number(item.node.attr("x")),
y: Number(item.node.attr("y"))
}))
};
record.moveMembership = moveMembership;
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
const x = moveMembership.startX +
((moveEvent.clientX - startClientX) / this.zoomFactor);
const y = moveMembership.startY +
((moveEvent.clientY - startClientY) / this.zoomFactor);
this.moveSubmapGroup(record, x, y, moveMembership);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
record.moveMembership = null;
this.editor.saveCurrentContextLayout();
this.editor.refreshSubmapVisibility();
this.editor.scheduleHistoryCommit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
installMarqueeSelection() {
this.marqueeMouseDownHandler = (event) => {
if (event.button !== 0) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest(
"[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
const start = this.editor.canvasPoint(event);
if (this.editor.pointNearConnector(start)) return;
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
const additive = event.ctrlKey || event.metaKey || event.shiftKey;
const surface = this.editor.surfaceElement() || this.canvas;
const marquee = document.createElement("div");
marquee.className = "rw-cmap-marquee";
Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" });
surface.append(marquee);
const cleanup = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
marquee.remove();
if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null;
};
const move = (moveEvent) => {
const point = this.editor.canvasPoint(moveEvent);
const left = Math.min(start.x, point.x);
const top = Math.min(start.y, point.y);
Object.assign(marquee.style, {
left: `${left}px`,
top: `${top}px`,
width: `${Math.abs(point.x - start.x)}px`,
height: `${Math.abs(point.y - start.y)}px`
});
};
const up = (upEvent) => {
const point = this.editor.canvasPoint(upEvent);
cleanup();
const bounds = {
left: Math.min(start.x, point.x),
top: Math.min(start.y, point.y),
right: Math.max(start.x, point.x),
bottom: Math.max(start.y, point.y)
};
if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) {
if (!additive) this.editor.clearSelection();
return;
}
if (!additive) this.editor.clearSelection(false);
const matches = this.items.filter((item) => {
if (!this.editor.isEffectiveItemVisible(item)) return false;
const left = Number(item.node.attr("x"));
const top = Number(item.node.attr("y"));
const right = left + Number(item.node.attr("width"));
const bottom = top + Number(item.node.attr("height"));
return right >= bounds.left && left <= bounds.right &&
bottom >= bounds.top && top <= bounds.bottom;
});
const expanded = new Set(matches);
for (const item of matches) {
if (!item.groupId) continue;
for (const member of this.items.filter((candidate) =>
candidate.groupId === item.groupId && this.editor.isItemVisible(candidate))) expanded.add(member);
}
for (const item of expanded) this.editor.selectedItems.add(item);
this.editor.selectedItem = matches.at(-1) || this.editor.selectedItem;
this.editor.selectedConnector = null;
this.editor.refreshSelectionDecoration();
this.editor.notifySelection();
debug("marquee selection applied", {
selectedIds: this.editor.selectedAll().map((item) => item.id)
});
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
this.activeMarqueeCleanup = cleanup;
};
this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler);
}
installCanvasPanning() {
this.canvasPanPointerDownHandler = (event) => {
const target = event.target instanceof Element ? event.target : null;
if (target && target.closest(
"[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
if (event.button !== 1 && !(event.button === 0 && event.altKey)) return;
event.preventDefault();
if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
const startX = event.clientX;
const startY = event.clientY;
const startScrollLeft = this.canvas.scrollLeft;
const startScrollTop = this.canvas.scrollTop;
const pointerId = event.pointerId;
this.canvas.classList.add("rw-cmap-canvas-panning");
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
this.canvas.scrollLeft = startScrollLeft - (moveEvent.clientX - startX);
this.canvas.scrollTop = startScrollTop - (moveEvent.clientY - startY);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.canvas.classList.remove("rw-cmap-canvas-panning");
if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.canvas.classList.remove("rw-cmap-canvas-panning");
if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
this.activeCanvasPanCleanup = cleanup;
};
this.canvas.addEventListener("pointerdown", this.canvasPanPointerDownHandler);
}
handleMapSelection(component, event) {
if (event.target instanceof Element &&
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
debug("cmap selection belongs to an editor control", elementDescription(event.target));
return;
}
const item = this.items.find((candidate) => candidate.node === component) || null;
const connector = this.connectors.find((candidate) => candidate.link === component) || null;
debug("selection callback received from cmap hit test", {
componentFound: Boolean(component),
itemId: item ? item.id : null,
connectorId: connector ? connector.id : null,
target: elementDescription(event.target)
});
if (item) {
const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey));
if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(item)) {
this.editor.selectedItem = item;
this.editor.refreshSelectionDecoration();
this.editor.notifySelection();
return;
}
this.editor.selectItem(item, { additive, toggle: additive });
return;
}
if (connector) {
this.editor.selectConnector(connector);
return;
}
if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.editor.clearSelection();
}
handleMapActivation(component, event) {
const item = this.items.find((candidate) => candidate.node === component) || null;
debug("activation callback received from cmap", {
itemId: item ? item.id : null,
kind: item ? item.kind : null,
pageSlug: item ? item.pageSlug : null
});
if (!item) return;
if (event && event.preventDefault) event.preventDefault();
if (item.kind === "phrase") {
this.editor.editPhraseInline(item);
return;
}
if (this.editor.onEditItem) {
this.editor.selectItem(item);
this.editor.onEditItem(item);
}
}
startResize(event, record) {
event.preventDefault();
event.stopPropagation();
record.autoWidth = false;
record.autoHeight = false;
record.fitContentPending = false;
const startX = event.clientX;
const startY = event.clientY;
const startWidth = Number(record.node.attr("width"));
const startHeight = Number(record.node.attr("height"));
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor));
record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor));
record.node.attr({ width: record.width, height: record.height });
record.node.redraw();
this.editor.decorateItem(record);
this.editor.redrawConnectorsFor(record);
this.editor.ensureCanvasExtent(Number(record.node.attr("x")) + record.width,
Number(record.node.attr("y")) + record.height);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.editor.decorateItem(record);
this.editor.scheduleHistoryCommit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
startRelationDrag(event, source) {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const start = this.editor.itemCenter(source);
const draft = this.createDraftLine(start);
this.editor.dragRelation = { source, draft };
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const point = this.editor.canvasPoint(moveEvent);
this.editor.ensureCanvasExtent(point.x, point.y);
draft.line.setAttribute("x2", String(point.x));
draft.line.setAttribute("y2", String(point.y));
draft.svg.setAttribute("width", String(Math.max(this.editor.logicalCanvasWidth(), point.x + 180)));
draft.svg.setAttribute("height", String(Math.max(this.editor.logicalCanvasHeight(), point.y + 180)));
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
const target = this.editor.itemAt(upEvent.clientX, upEvent.clientY);
const point = this.editor.canvasPoint(upEvent);
draft.svg.remove();
this.editor.dragRelation = null;
if (!target) {
this.editor.ensureCanvasExtent(point.x, point.y);
const parentSubmap = this.editor.submapAtPoint(point);
debug("relation dropped on empty canvas", {
sourceId: source.id,
point,
parentSubmapId: parentSubmap ? parentSubmap.id : null
});
if (this.editor.onCreateConnectedItem) this.editor.onCreateConnectedItem({ source, point, parentSubmap });
return;
}
if (target === source) return;
this.finishRelation(source, target, event.altKey || upEvent.altKey);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
finishRelation(source, target, direct = false) {
if (source.kind === "phrase" && target.kind !== "phrase") {
this.editor.addConnector(source, target, true);
this.editor.reconcilePhraseMembership(source);
this.editor.refreshSubmapVisibility();
this.editor.selectItem(source);
return;
}
if (source.kind !== "phrase" && target.kind === "phrase") {
this.editor.addConnector(source, target, false);
this.editor.reconcilePhraseMembership(target);
this.editor.refreshSubmapVisibility();
this.editor.selectItem(target);
return;
}
if (source.kind === "phrase" && target.kind === "phrase") return;
if (direct) {
const connector = this.editor.addConnector(source, target, true);
this.editor.refreshSubmapVisibility();
this.editor.selectConnector(connector);
return;
}
this.editor.connectWithPhrase(source, target, "?????", true);
}
createDraftLine(start) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.classList.add("rw-cmap-draft-layer");
svg.setAttribute("width", String(this.editor.logicalCanvasWidth()));
svg.setAttribute("height", String(this.editor.logicalCanvasHeight()));
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", String(start.x));
line.setAttribute("y1", String(start.y));
line.setAttribute("x2", String(start.x));
line.setAttribute("y2", String(start.y));
line.setAttribute("class", "rw-cmap-draft-line");
svg.append(line);
(this.editor.surfaceElement() || this.canvas).append(svg);
return { svg, line };
}
destroy() {
if (this.marqueeMouseDownHandler) {
this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler);
this.marqueeMouseDownHandler = null;
}
if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
if (this.canvasPanPointerDownHandler) {
this.canvas.removeEventListener("pointerdown", this.canvasPanPointerDownHandler);
this.canvasPanPointerDownHandler = null;
}
if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
}
}
@@ -0,0 +1,532 @@
"use strict";
import { debug, normalizeConceptTags } from "../cmap-utils.js";
let copiedConceptReferences = [];
export class CmapSelectionController {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get selectedItem() { return this.editor.selectedItem; }
set selectedItem(val) { this.editor.selectedItem = val; }
get selectedItems() { return this.editor.selectedItems; }
get selectedConnector() { return this.editor.selectedConnector; }
set selectedConnector(val) { this.editor.selectedConnector = val; }
selectItem(record, options = {}) {
if (!record) {
this.clearSelection();
return;
}
const additive = Boolean(options.additive);
const toggle = Boolean(options.toggle);
const groupRecords = record.groupId && options.expandGroup !== false ?
this.items.filter((item) => item.groupId === record.groupId && this.editor.isItemVisible(item)) :
[record];
debug("selectItem called", {
requestedId: record.id,
requestedKind: record.kind,
additive,
groupId: record.groupId,
previousIds: this.selectedAll().map((item) => item.id)
});
if (!additive) this.clearSelection(false);
const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item));
for (const item of groupRecords) {
if (remove) {
this.selectedItems.delete(item);
} else {
this.selectedItems.add(item);
}
}
this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record;
this.selectedConnector = null;
this.refreshSelectionDecoration();
debug("selection applied", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
selectedIds: this.selectedAll().map((item) => item.id),
selectionCount: this.selectedItems.size
});
this.notifySelection();
}
refreshSelectionDecoration() {
for (const item of this.items) {
const element = item.node.element();
const selected = this.selectedItems.has(item);
if (element) {
element.classList.toggle("rw-cmap-selected", selected);
element.classList.toggle(
"rw-cmap-selected-primary", selected && item === this.selectedItem);
if (selected) {
element.setAttribute("aria-selected", "true");
item.node.toFront();
} else {
element.removeAttribute("aria-selected");
}
this.editor.removeHandles(element);
if (selected && item === this.selectedItem) this.editor.ensureHandles(item, element);
}
if (item.kind === "submap") this.editor.updateSubmapFrame(item);
}
this.editor.submaps.refreshGroupSelection();
}
selectConnector(record) {
this.clearSelection();
this.selectedConnector = record;
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
record.link.redraw();
this.notifySelection();
}
clearSelection(notify = true) {
const clearedItemIds = this.selectedAll().map((item) => item.id);
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
for (const item of this.selectedItems) {
const element = item.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.classList.remove("rw-cmap-selected-primary");
element.removeAttribute("aria-selected");
this.editor.removeHandles(element);
}
}
if (this.selectedConnector) {
const connector = this.selectedConnector;
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
connector.link.redraw();
}
this.selectedItem = null;
this.selectedItems.clear();
this.selectedConnector = null;
if (clearedItemIds.length || clearedConnectorId) {
debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId });
}
if (notify) this.notifySelection();
}
selected() {
return this.selectedItem;
}
selectedAll() {
return Array.from(this.selectedItems);
}
storeConceptReferences(records) {
if (!records.length) return 0;
const referencesById = new Map(records.map((source) =>
[source.conceptId, {
conceptId: source.conceptId,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
externalUrl: source.externalUrl,
imageSource: source.imageSource,
width: Number(source.node.attr("width")),
height: Number(source.node.attr("height")),
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
fontFamily: source.fontFamily,
fontSize: source.fontSize,
fontWeight: source.fontWeight,
fontStyle: source.fontStyle,
synopsisTextColor: source.synopsisTextColor,
synopsisFontFamily: source.synopsisFontFamily,
synopsisFontSize: source.synopsisFontSize,
synopsisFontWeight: source.synopsisFontWeight,
synopsisFontStyle: source.synopsisFontStyle
}]));
copiedConceptReferences = Array.from(referencesById.values());
return copiedConceptReferences.length;
}
copySelectionReferences() {
const selected = this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase");
const copied = this.storeConceptReferences(selected);
if (!copied) return 0;
this.notifySelection();
return copied;
}
canCutSelectionReferences() {
return this.selectedAll().some((item) =>
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
}
cutSelectionReferences() {
const cuttable = this.selectedAll().filter((item) =>
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
if (!cuttable.length) return 0;
const copied = this.storeConceptReferences(cuttable);
if (!copied) return 0;
this.clearSelection(false);
for (const record of cuttable) this.selectedItems.add(record);
this.selectedItem = cuttable.at(-1) || null;
this.deleteSelection();
return copied;
}
canPasteConceptReferences() {
return copiedConceptReferences.length > 0;
}
pasteConceptReferences() {
const sources = copiedConceptReferences;
if (!sources.length) return [];
this.clearSelection(false);
const parentSubmap = this.editor.activeMapRoot || null;
const pasted = sources.map((source, index) => this.editor.addItem({
conceptId: source.conceptId,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: source.aspects,
tags: source.tags,
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
externalUrl: source.externalUrl,
parentCmapLink: false,
imageSource: source.imageSource,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: 120 + (index * 36),
y: 120 + (index * 36),
width: source.width,
height: source.height,
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
fontFamily: source.fontFamily,
fontSize: source.fontSize,
fontWeight: source.fontWeight,
fontStyle: source.fontStyle,
synopsisTextColor: source.synopsisTextColor,
synopsisFontFamily: source.synopsisFontFamily,
synopsisFontSize: source.synopsisFontSize,
synopsisFontWeight: source.synopsisFontWeight,
synopsisFontStyle: source.synopsisFontStyle
}));
for (const record of pasted) this.selectedItems.add(record);
this.selectedItem = pasted.at(-1) || null;
this.refreshSelectionDecoration();
this.notifySelection();
return pasted;
}
selectAll() {
this.clearSelection(false);
for (const item of this.items) {
if (this.editor.isEffectiveItemVisible(item)) this.selectedItems.add(item);
}
this.selectedItem = this.selectedAll().at(-1) || null;
this.refreshSelectionDecoration();
this.notifySelection();
return this.selectedAll();
}
layoutSelectionRecords() {
return this.selectedAll().filter((item) => this.editor.isEffectiveItemVisible(item));
}
canLayoutSelection(command) {
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
return this.layoutSelectionRecords().length >= minimum;
}
applySelectionLayout(command) {
const records = this.layoutSelectionRecords();
if (!this.canLayoutSelection(command)) return false;
const boxes = records.map((record) => ({
record,
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y")),
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height"))
}));
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
const updates = new Map(boxes.map(({ record }) => [record, {}]));
const referenceRight = reference.x + reference.width;
const referenceCenter = reference.x + (reference.width / 2);
const referenceBottom = reference.y + reference.height;
const referenceMiddle = reference.y + (reference.height / 2);
if (["same-width", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).width = reference.width;
}
if (["same-height", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).height = reference.height;
}
if (command === "align-left") {
for (const box of boxes) updates.get(box.record).x = reference.x;
}
if (command === "align-right") {
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
}
if (command === "align-center") {
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
}
if (command === "align-top") {
for (const box of boxes) updates.get(box.record).y = reference.y;
}
if (command === "align-bottom") {
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
}
if (command === "align-middle") {
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
}
if (command === "distribute-horizontal") {
const ordered = [...boxes].sort((a, b) =>
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionLeft = ordered[0].x;
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
let cursor = distributionLeft;
for (const box of ordered) {
updates.get(box.record).x = cursor;
cursor += box.width + gap;
}
}
if (command === "distribute-vertical") {
const ordered = [...boxes].sort((a, b) =>
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionTop = ordered[0].y;
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
let cursor = distributionTop;
for (const box of ordered) {
updates.get(box.record).y = cursor;
cursor += box.height + gap;
}
}
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
"align-center", "align-top", "align-bottom", "align-middle",
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
this.editor.scheduleHistoryCommit();
for (const record of records) {
const attributes = updates.get(record);
if (attributes.width !== undefined) {
record.width = attributes.width;
record.autoWidth = false;
}
if (attributes.height !== undefined) {
record.height = attributes.height;
record.autoHeight = false;
}
record.node.attr(attributes);
record.node.redraw();
}
this.editor.saveCurrentContextLayout();
this.editor.refreshConnectorGeometry();
for (const submap of this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth)) {
this.editor.updateSubmapFrame(submap);
}
this.refreshSelectionDecoration();
debug("selection layout applied", {
command,
referenceItemId: reference.record.id,
itemIds: records.map((record) => record.id)
});
return true;
}
canGroupSelection() {
const selected = this.selectedAll();
return selected.length >= 2 &&
selected.every((item) => item.parentSubmap === selected[0].parentSubmap);
}
groupSelection(options = {}) {
const selected = this.selectedAll();
if (!this.canGroupSelection()) return false;
const parentSubmap = selected[0].parentSubmap;
const left = Math.min(...selected.map((item) => Number(item.node.attr("x"))));
const top = Math.min(...selected.map((item) => Number(item.node.attr("y"))));
const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap";
const submap = this.editor.addItem({
...options,
kind: "submap",
label,
childMap: options.childMap || label,
synopsis: options.synopsis || "Grouped sub-concept map.",
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: (options.x === undefined || options.x === null) ? left : Number(options.x),
y: (options.y === undefined || options.y === null) ? Math.max(20, top - 105) : Number(options.y),
backgroundColor: options.backgroundColor || "#edf7e8",
borderColor: options.borderColor || "#57834a"
});
submap.expanded = true;
submap.submapInitialized = true;
for (const item of selected) {
item.groupId = null;
item.parentSubmap = submap;
this.editor.updateSubmapDepth(item, submap.submapDepth + 1);
}
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.applyCurrentContextLayout();
this.selectItem(submap);
debug("selection grouped as submap", {
submapId: submap.id,
itemIds: selected.map((item) => item.id)
});
return submap;
}
canUngroupSelection() {
return this.selectedAll().some((item) =>
Boolean(item.groupId) ||
(item.kind === "submap" && !item.separateMap) ||
Boolean(item.parentSubmap && item.parentSubmap !== this.editor.activeMapRoot));
}
ungroupSelection() {
const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean));
const affected = this.items.filter((item) => groupIds.has(item.groupId));
for (const item of affected) item.groupId = null;
const selected = this.selectedAll();
const selectedSubmaps = new Set(selected.filter((item) =>
item.kind === "submap" && !item.separateMap));
const liftedChildren = new Set();
for (const submap of selectedSubmaps) {
const parent = submap.parentSubmap;
const children = this.items.filter((item) => item.parentSubmap === submap);
for (const child of children) {
liftedChildren.add(child);
child.parentSubmap = parent;
this.editor.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
}
submap.expanded = false;
submap.submapInitialized = false;
submap.childMap = null;
this.editor.updateItem(submap, { kind: "concept" });
affected.push(submap, ...children);
}
for (const item of selected) {
if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap ||
item.parentSubmap === this.editor.activeMapRoot) continue;
const parent = item.parentSubmap.parentSubmap;
item.parentSubmap = parent;
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
affected.push(item);
}
if (!affected.length) return false;
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.refreshSubmapVisibility();
this.refreshSelectionDecoration();
debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
this.notifySelection();
this.editor.scheduleHistoryCommit();
return true;
}
deleteSelection() {
const records = new Set(this.selectedAll().filter((item) => item !== this.editor.activeMapRoot));
for (const record of Array.from(records)) {
if (record.kind === "submap") {
for (const item of this.items) {
if (this.editor.isDescendantOf(item, record)) records.add(item);
}
}
}
const connectors = new Set(this.connectors.filter((connector) =>
connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target)));
const affectedPhrases = new Set();
for (const connector of connectors) {
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
affectedPhrases.add(connector.source);
}
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
affectedPhrases.add(connector.target);
}
}
let foundOrphan = true;
while (foundOrphan) {
foundOrphan = false;
const remainingConnectors = this.connectors.filter((connector) =>
!connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target));
for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) {
const hasSource = remainingConnectors.some((connector) => connector.target === phrase);
const hasTarget = remainingConnectors.some((connector) => connector.source === phrase);
if (hasSource && hasTarget) continue;
records.add(phrase);
for (const connector of this.connectors) {
if (connector.source !== phrase && connector.target !== phrase) continue;
connectors.add(connector);
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
affectedPhrases.add(connector.source);
}
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
affectedPhrases.add(connector.target);
}
}
foundOrphan = true;
}
}
if (!records.size && !connectors.size) return false;
this.clearSelection(false);
for (const connector of connectors) {
connector.link.remove();
this.editor.model.conceptMap.removeConnector(connector.id);
}
this.editor.connectors = this.connectors.filter((connector) => !connectors.has(connector));
for (const record of records) {
if (record.mapReference && record.mapReference.id) this.editor.conceptMaps.delete(record.mapReference.id);
record.node.remove();
this.editor.model.conceptMap.removeItem(record.id);
}
if (records.size && this.editor.unresolvedConnectors.length) {
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
this.editor.unresolvedConnectors = this.editor.unresolvedConnectors.filter((connector) =>
!deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
}
this.editor.items = this.items.filter((item) => !records.has(item));
this.editor.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.refreshSubmapVisibility();
this.notifySelection();
debug("selection deleted", {
itemIds: Array.from(records).map((item) => item.id),
connectorIds: Array.from(connectors).map((connector) => connector.id)
});
this.editor.scheduleHistoryCommit();
return true;
}
notifySelection() {
if (this.editor.onSelectionChange) {
this.editor.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
}
}
}
@@ -0,0 +1,340 @@
/**
* Coordinates submap membership, navigation and visibility for the wiki editor.
*
* The editor remains responsible for item storage and drawing. This controller
* owns the rules for the active map context and delegates rendering and model
* synchronization through the supplied editor instance.
*/
export class CmapSubmapController {
/**
* goal : Create the controller for one wiki CMap editor.
* pre : editor owns the items, view, model and editor callbacks.
* post : Submap operations can delegate rendering and model work to editor.
* result : A CmapSubmapController instance.
* internals : The controller keeps no duplicate item state; its accessors
* read the editor's active context and map history when an operation runs.
*
* @param {object} editor The editor facade that owns items and rendering.
*/
constructor(editor) {
this.editor = editor;
this.diagramGroups = new Map();
}
get items() { return this.editor.items; }
get activeMapRoot() { return this.editor.activeMapRoot; }
set activeMapRoot(value) { this.editor.activeMapRoot = value; }
get mapHistory() { return this.editor.mapHistory; }
get onMapChange() { return this.editor.onMapChange; }
/** Return whether record is nested below submap. */
isDescendantOf(record, submap) {
let parent = record.parentSubmap;
while (parent) {
if (parent === submap) return true;
parent = parent.parentSubmap;
}
return false;
}
/** Return whether record belongs to the currently opened map context. */
itemInsideActiveMap(record) {
return Boolean(this.activeMapRoot &&
(record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot)));
}
/** Return the persistence key for the active map context. */
mapContextKey(root = this.activeMapRoot) {
return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root";
}
/**
* Determine visibility from hidden contexts, active root and expanded parents.
* The result controls both item rendering and connector endpoint projection.
*/
isItemVisible(record) {
const context = this.mapContextKey();
if (record !== this.activeMapRoot && record.hiddenContexts.has(context)) return false;
if (this.activeMapRoot) {
if (record === this.activeMapRoot) return true;
if (!this.isDescendantOf(record, this.activeMapRoot)) return false;
let parent = record.parentSubmap;
while (parent && parent !== this.activeMapRoot) {
if (!parent.expanded) return false;
parent = parent.parentSubmap;
}
return parent === this.activeMapRoot;
}
let parent = record.parentSubmap;
while (parent) {
if (!parent.expanded) return false;
parent = parent.parentSubmap;
}
return true;
}
/** Return hidden non-phrase items ordered for the visibility picker. */
hiddenItemsInCurrentContext() {
const context = this.mapContextKey();
return this.items
.filter((item) => item !== this.activeMapRoot && item.kind !== "phrase" &&
item.hiddenContexts.has(context))
.sort((left, right) => left.label.localeCompare(right.label));
}
/** Return whether the current selection contains an item that can be hidden. */
canHideSelectionInCurrentContext() {
return !this.activeMapRoot && this.editor.selectedAll().some((item) => item.parentSubmap &&
item.kind !== "phrase" && this.isItemVisible(item));
}
/** Hide selected child concepts in the current root context. */
hideSelectionInCurrentContext() {
const context = this.mapContextKey();
if (this.activeMapRoot) return false;
const selected = this.editor.selectedAll().filter((item) => item.parentSubmap &&
item.kind !== "phrase" && this.isItemVisible(item));
if (!selected.length) return false;
this.editor.scheduleHistoryCommit();
for (const item of selected) item.hiddenContexts.add(context);
this.editor.clearSelection();
this.refreshVisibility();
if (this.editor.onVisibilityChange) {
this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext());
}
return true;
}
/** Show one item again in the current root context. */
showItemInCurrentContext(record) {
if (!record) return false;
const context = this.mapContextKey();
if (!record.hiddenContexts.has(context)) return false;
this.editor.scheduleHistoryCommit();
record.hiddenContexts.delete(context);
this.refreshVisibility();
if (this.editor.onVisibilityChange) {
this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext());
}
return true;
}
/** Populate a lazy submap once, then reuse its editor records. */
ensureSubmapContents(record) {
if (record.submapInitialized) return;
record.submapInitialized = true;
if (this.editor.onPopulateSubMap) this.editor.onPopulateSubMap(record, this.editor);
}
/** Expand/collapse a submap or open its separate map representation. */
toggleSubmap(record, expanded = !record.expanded) {
if (!record || record.kind !== "submap") return false;
if (record.separateMap && !record.cmapSlug) {
record.expanded = false;
if (record === this.activeMapRoot) {
this.refreshVisibility();
return false;
}
return this.openSubmapMap(record);
}
this.editor.saveCurrentContextLayout();
if (expanded) this.ensureSubmapContents(record);
record.expanded = Boolean(expanded);
if (record.expanded) this.editor.applyCurrentContextLayout();
else this.refreshVisibility();
const element = record.node.element();
if (element) this.editor.ensureSubmapToggle(record, element);
if (this.editor.onOpenSubMap) this.editor.onOpenSubMap(record, record.expanded);
this.editor.scheduleHistoryCommit();
return record.expanded;
}
/** Open a separate submap and preserve the previous map on the navigation stack. */
openSubmapMap(record) {
if (!record || record.kind !== "submap" || !record.separateMap) return false;
if (record === this.activeMapRoot) return true;
this.ensureSubmapContents(record);
this.editor.clearSelection();
this.editor.saveCurrentContextLayout();
if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot);
this.activeMapRoot = record;
this.editor.applyCurrentContextLayout();
if (this.onMapChange) this.onMapChange(record.mapReference, record);
return true;
}
/** Return from a child context to the root map. */
openRootMap() {
if (!this.activeMapRoot) return false;
this.editor.clearSelection();
this.editor.saveCurrentContextLayout();
this.activeMapRoot = null;
this.editor.mapHistory = [];
this.editor.applyCurrentContextLayout();
if (this.onMapChange) this.onMapChange(null, null);
return true;
}
/** Return whether a parent context is available. */
canStepBackWithinMap() {
return this.mapHistory.length > 0;
}
/** Open exactly one parent context from the map navigation stack. */
openParentMap() {
if (!this.activeMapRoot) return false;
this.editor.clearSelection();
this.editor.saveCurrentContextLayout();
this.activeMapRoot = this.editor.mapHistory.pop() || null;
this.editor.applyCurrentContextLayout();
const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
return true;
}
/** Promote an embedded submap into a separately addressable CMap reference. */
promoteSubmap(record, name) {
if (!record || record.kind !== "submap") return null;
this.ensureSubmapContents(record);
record.childMap = String(name || record.label).trim() || record.label;
record.separateMap = true;
record.mapReference = {
id: `cmap-${record.id}`,
title: record.childMap,
rootItemId: record.id,
itemIds: this.items
.filter((item) => this.isDescendantOf(item, record))
.map((item) => item.id)
};
this.editor.conceptMaps.set(record.mapReference.id, record.mapReference);
record.expanded = false;
this.editor.updateItem(record, { synopsis: `Concept map: ${record.childMap}` });
if (this.editor.onSubMapPromoted) this.editor.onSubMapPromoted(record);
this.refreshVisibility();
return record.mapReference;
}
/** Prepare a submap model for storage as a separate CMap. */
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
if (!record || record.kind !== "submap") return null;
this.ensureSubmapContents(record);
return this.editor.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
}
/** Apply one submap record's current frame appearance immediately. */
updateGroupAppearance(record) {
const group = this.diagramGroups.get(record);
if (!group) return false;
group.setAppearance({
label: record.label,
backgroundColor: record.submapBackgroundColor || "#edf7e8",
borderColor: record.submapBorderColor || "#57834a"
});
return true;
}
/**
* Synchronize wiki submap membership with generic engine groups.
* Groups own the frame DOM; the editor remains responsible for wiki actions.
*/
refreshGroups() {
if (!this.editor.map || typeof this.editor.map.group !== "function") return;
const submaps = this.items
.filter((item) => item.kind === "submap")
.sort((left, right) => right.submapDepth - left.submapDepth);
const current = new Set(submaps);
for (const [record, group] of this.diagramGroups) {
if (!current.has(record)) {
group.destroy();
this.diagramGroups.delete(record);
}
}
for (const record of submaps) {
if (this.diagramGroups.has(record)) continue;
const group = this.editor.map.group({
label: record.label,
className: "rw-cmap-submap-frame",
backgroundColor: record.submapBackgroundColor || "#edf7e8",
borderColor: record.submapBorderColor || "#57834a",
padding: 34,
depth: record.submapDepth,
expanded: record.expanded,
manageVisibility: false,
onPointerDown: (event) => {
if (event.target?.closest?.(".rw-cmap-submap-frame-toggle")) return;
this.editor.startSubmapFrameDrag(event, record);
},
onDoubleClick: (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
if (this.editor.onEditItem) this.editor.onEditItem(record);
}
});
group.onToggle((_group, expanded) => this.toggleSubmap(record, expanded));
this.diagramGroups.set(record, group);
}
for (const record of submaps) {
const group = this.diagramGroups.get(record);
for (const member of [...group.members]) group.remove(member);
group.expanded = Boolean(record.expanded && record !== this.activeMapRoot &&
this.isItemVisible(record));
for (const child of this.items.filter((item) => item.parentSubmap === record)) {
if (child.kind === "submap") {
const childGroup = this.diagramGroups.get(child);
if (childGroup) group.add(childGroup);
}
// A nested submap's anchor is part of this group's layout. Its child
// group is added separately so that the nested contents get their own frame.
if (child.node) group.add(child.node);
}
group.setAppearance({
label: record.label,
backgroundColor: record.submapBackgroundColor || "#edf7e8",
borderColor: record.submapBorderColor || "#57834a"
});
group.depth = record.submapDepth;
group.redraw();
const element = group.element();
if (element) {
element.classList.toggle("rw-cmap-submap-frame-selected",
this.editor.selectedItems.has(record));
element.classList.toggle("rw-cmap-submap-frame-selected-primary",
this.editor.selectedItems.has(record) && this.editor.selectedItem === record);
}
this.editor.updateSubmapAnchorLine(record, group.bounds());
}
}
/** Update selection styling on already rendered group frames. */
refreshGroupSelection() {
for (const [record, group] of this.diagramGroups) {
const element = group.element();
if (!element) continue;
element.classList.toggle("rw-cmap-submap-frame-selected",
this.editor.selectedItems.has(record));
element.classList.toggle("rw-cmap-submap-frame-selected-primary",
this.editor.selectedItems.has(record) && this.editor.selectedItem === record);
}
}
/**
* Reconcile item visibility, projected connector endpoints and submap frames.
* The editor still owns the drawing operations; this method coordinates their
* order after a context or membership change.
*/
refreshVisibility() {
for (const item of this.items) item.node.visible(this.editor.isEffectiveItemVisible(item));
for (const connector of this.editor.connectors) {
this.editor.applyConnectorVisualEndpoints(connector,
this.editor.connectorEndpoint(connector.source),
this.editor.connectorEndpoint(connector.target));
}
this.refreshGroups();
for (const submap of this.items.filter((item) => item.kind === "submap")) {
const element = submap.node.element();
if (element) this.editor.ensureSubmapToggle(submap, element);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Public handle for one component rendered by a DiagramEngine.
*
* A handle exposes only the operations used by the CMap editor. The drawing
* object remains private to the engine so application code cannot depend on
* the representation inherited from the original renderer.
*/
export class DiagramComponent {
constructor(engine, component, attributeNames) {
this.engine = engine;
this.component = component;
this.attributeNames = attributeNames;
this.baseVisible = true;
}
/** Read or update the supported rendering attributes. */
attr(name, value) {
if (name === undefined) {
const attributes = {};
for (const attributeName of this.attributeNames) {
attributes[attributeName] = this.component[attributeName]();
}
return attributes;
}
if (isPlainObject(name)) {
for (const [attributeName, attributeValue] of Object.entries(name)) {
this.attr(attributeName, attributeValue);
}
return this;
}
if (!this.attributeNames.includes(name)) return this;
if (value === undefined) return this.component[name]();
this.component[name](value);
return this;
}
/** Remove this component from its diagram. */
remove() {
this.engine.removeComponent(this);
}
/** Move this component to the front of its own rendering band. */
toFront() {
this.engine.drawingSurface.toFront(this.component);
return this;
}
/** Return the concrete element currently rendering this component. */
element() {
return this.component.element();
}
/** Immediately render the current component state. */
redraw() {
this.component.redraw();
return this;
}
/** Read or change whether the component participates in the presentation. */
visible(value) {
if (value === undefined) return this.component.visible !== false;
this.baseVisible = Boolean(value);
this.engine.applyFilter(this);
return this.component.visible;
}
/** Register a callback invoked after this component has been rendered. */
onRendered(handler) {
validateOptionalHandler(handler, "render");
this.component.renderedHandler = handler ?
(element) => handler(this, element) : null;
if (handler && this.component.element()) handler(this, this.component.element());
return this;
}
/** Read or change whether the component can be dragged. */
draggable(enabled) {
if (enabled === undefined) {
return this.engine.drawingSurface.dragEnabled(this.component);
}
if (enabled) this.engine.drawingSurface.enableDrag(this.component);
else this.engine.drawingSurface.disableDrag(this.component);
return this;
}
}
/** Return a new object containing only supported input attributes. */
export function pickAttributes(attributes, names) {
if (attributes === undefined) return {};
if (!isPlainObject(attributes)) throw new TypeError("Invalid component attributes");
const selected = {};
for (const name of names) {
if (name in attributes) selected[name] = attributes[name];
}
return selected;
}
/** Validate an optional event handler at the public engine boundary. */
export function validateOptionalHandler(handler, meaning) {
if (handler !== null && handler !== undefined && typeof handler !== "function") {
throw new TypeError(`Invalid ${meaning} handler`);
}
}
/** Determine whether a value is a plain attributes object. */
function isPlainObject(value) {
return typeof value === "object" && value !== null &&
Object.prototype.toString.call(value) === "[object Object]";
}
+184
View File
@@ -0,0 +1,184 @@
import { DrawingSurface } from "./drawing-core.js";
import { validateOptionalHandler } from "./diagram-component.js";
import { DiagramLink } from "./diagram-link.js";
import { DiagramNode } from "./diagram-node.js";
import { DiagramGroup } from "./diagram-group.js";
/**
* Render and interact with a diagram of nodes and links.
*
* DiagramEngine is the complete public boundary of the drawing engine. It
* translates low-level hit-test results to DiagramNode and DiagramLink
* handles and owns every rendering object's lifetime.
*/
export class DiagramEngine {
constructor(element) {
this.drawingSurface = new DrawingSurface(element);
this.handles = new Map();
this.selectionHandler = null;
this.activationHandler = null;
this.filter = null;
this.groups = new Set();
this.destroyed = false;
this.drawingSurface.selectionHandler = (component, event) => {
if (this.selectionHandler) {
this.selectionHandler(component ? this.handleFor(component) : null, event);
}
};
this.drawingSurface.activationHandler = (component, event) => {
if (this.activationHandler) {
this.activationHandler(component ? this.handleFor(component) : null, event);
}
};
}
/** Register a callback for a single hit-tested component selection. */
onSelection(handler) {
validateOptionalHandler(handler, "selection");
this.selectionHandler = handler || null;
return this;
}
/** Register a callback for activation of one hit-tested component. */
onActivation(handler) {
validateOptionalHandler(handler, "activation");
this.activationHandler = handler || null;
return this;
}
/** Create and render a node owned by this engine. */
node(attributes) {
this.ensureActive();
const node = new DiagramNode(this, attributes);
this.addComponent(node);
return node;
}
/** Create and render a link owned by this engine. */
link(attributes) {
this.ensureActive();
const link = new DiagramLink(this, attributes);
this.addComponent(link);
return link;
}
/**
* Create a generic group frame for nodes and nested groups.
* @returns {DiagramGroup} A group owned by this engine.
*/
group(options) {
this.ensureActive();
const group = new DiagramGroup(this, options);
this.groups.add(group);
return group;
}
/**
* goal : Install a policy that controls component visibility.
* pre : filter is null or a function receiving a public component handle.
* post : All existing components use the new policy immediately.
* result : This engine, for fluent setup.
* internals : A policy may return a boolean or `{ visible }`; the component's
* own visibility remains the base value and is combined with the policy.
*/
setFilter(filter) {
if (filter !== null && filter !== undefined && typeof filter !== "function") {
throw new TypeError("A diagram filter must be a function");
}
this.filter = filter || null;
for (const handle of this.handles.values()) this.applyFilter(handle);
for (const group of this.groups) group.redraw();
return this;
}
/** Read or update the diagram zoom factor. */
zoom(value) {
if (value === undefined) return this.drawingSurface.zoomFactor;
const factor = Number(value);
if (!Number.isFinite(factor) || factor <= 0) {
throw new TypeError("Invalid zoom factor");
}
this.drawingSurface.zoomFactor = factor;
const element = this.drawingSurface.element();
if (element) element.style.zoom = String(factor);
return factor;
}
/** Destroy the surface and all nodes and links created through this engine. */
destroy() {
if (this.destroyed) return;
const element = this.drawingSurface.element();
this.selectionHandler = null;
this.activationHandler = null;
this.drawingSurface.selectionHandler = null;
this.drawingSurface.activationHandler = null;
for (const group of this.groups) group.destroy();
this.groups.clear();
for (const component of this.drawingSurface.componentList().toArray()) {
component.dispose();
component.parentElement(null);
}
this.drawingSurface.dispose();
this.handles.clear();
this.destroyed = true;
if (element && element.parentNode) element.parentNode.removeChild(element);
}
/** Return the public handle associated with a low-level drawing object. */
handleFor(component) {
return this.handles.get(component) || null;
}
/** Add a newly constructed public component to the drawing surface. */
addComponent(handle) {
this.handles.set(handle.component, handle);
this.drawingSurface.add(handle.component);
this.applyFilter(handle);
}
/** Remove a public component and forget its low-level drawing object. */
removeComponent(handle) {
if (!handle || handle.engine !== this || !this.handles.has(handle.component)) return;
this.drawingSurface.remove(handle.component);
this.handles.delete(handle.component);
}
/** Return the DOM surface on which components and group frames are drawn. */
surfaceElement() {
return this.drawingSurface.element();
}
/** Apply the current base visibility and optional external filter to a handle. */
applyFilter(handle) {
const decision = this.filter ? this.filter(handle) : true;
let visible = typeof decision === "boolean" ? decision : decision?.visible !== false;
if (handle instanceof DiagramLink) {
const source = handle.sourceNode();
const target = handle.targetNode();
visible = visible && (!source || source.visible()) && (!target || target.visible());
}
handle.component.visible = handle.baseVisible && visible;
handle.component.redraw();
if (handle instanceof DiagramNode) {
for (const candidate of this.handles.values()) {
if (!(candidate instanceof DiagramLink)) continue;
if (candidate.sourceNode() === handle || candidate.targetNode() === handle) {
this.applyFilter(candidate);
}
}
}
for (const group of this.groups) {
if (group.members.has(handle)) group.redraw();
}
}
/** Reject operations after the engine and its DOM surface were destroyed. */
ensureActive() {
if (this.destroyed) throw new Error("The diagram engine has been destroyed");
}
}
+204
View File
@@ -0,0 +1,204 @@
import { DiagramComponent } from "./diagram-component.js";
/**
* Render a nested group frame around diagram components.
*
* A group is a view-level container, not a domain model. It owns membership,
* expanded state and frame geometry while DiagramEngine continues to own the
* lifetime of nodes and links. Application code can map a wiki submap or any
* other grouping concept onto this generic abstraction.
*/
export class DiagramGroup {
/**
* goal : Create an empty diagram group attached to one engine.
* pre : engine is a DiagramEngine and options contains only presentation data.
* post : The group has no members and has not yet rendered a frame.
* result : A DiagramGroup instance.
* internals : Membership is kept as a Set so nested groups and repeated add
* operations remain deterministic; bounds are calculated from member handles.
*/
constructor(engine, options = {}) {
if (!engine) throw new TypeError("A diagram engine is required");
this.engine = engine;
this.label = String(options.label || "");
this.className = String(options.className || "cmap-group-frame");
this.backgroundColor = options.backgroundColor || "transparent";
this.borderColor = options.borderColor || "#5d6d7e";
this.padding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 24;
this.depth = Number.isFinite(Number(options.depth)) ? Number(options.depth) : 0;
this.expanded = options.expanded !== false;
this.manageVisibility = options.manageVisibility !== false;
this.members = new Set();
this.elementValue = null;
this.onToggleHandler = null;
this.onPointerDownHandler = options.onPointerDown || null;
this.onDoubleClickHandler = options.onDoubleClick || null;
}
/**
* goal : Add one node or nested group to this group.
* pre : member belongs to the same DiagramEngine.
* post : The member contributes to group bounds and rendering.
* result : This group, for fluent setup.
*/
add(member) {
if (!(member instanceof DiagramComponent) && !(member instanceof DiagramGroup)) {
throw new TypeError("A diagram group member is required");
}
if (member.engine !== this.engine) throw new TypeError("Group member belongs to another engine");
this.members.add(member);
this.redraw();
return this;
}
/** Remove a member and update the frame. */
remove(member) {
this.members.delete(member);
this.redraw();
return this;
}
/** Register the callback invoked when the generic frame toggle is clicked. */
onToggle(handler) {
if (handler !== null && handler !== undefined && typeof handler !== "function") {
throw new TypeError("Invalid group toggle handler");
}
this.onToggleHandler = handler || null;
return this;
}
/**
* goal : Set the visual appearance of the group frame.
* pre : values contains optional CSS colour values.
* post : The next redraw uses the supplied background and border colours.
* result : This group, for fluent setup.
* internals : Values are kept on the group and applied as CSS custom
* properties in redraw(), allowing application-specific frame styles.
*/
setAppearance(values = {}) {
if (values.backgroundColor !== undefined) this.backgroundColor = String(values.backgroundColor);
if (values.borderColor !== undefined) this.borderColor = String(values.borderColor);
if (values.label !== undefined) this.label = String(values.label);
this.redraw();
return this;
}
/**
* goal : Change whether group contents are shown.
* pre : expanded is boolean-coercible.
* post : The frame and all member components reflect the new state.
* result : The resulting expanded state.
* internals : A collapsed group hides direct members; nested groups redraw
* themselves so their own frames disappear with the enclosing group.
*/
setExpanded(expanded) {
this.expanded = Boolean(expanded);
if (this.manageVisibility) {
for (const member of this.members) {
if (member instanceof DiagramGroup) member.setExpanded(this.expanded && member.expanded);
else member.visible(this.expanded);
}
}
this.redraw();
return this.expanded;
}
/** Return the union bounds of visible member nodes and nested groups. */
bounds() {
const members = [...this.members]
.map((member) => member instanceof DiagramGroup ? member.bounds() :
(member.visible() ? this.componentBounds(member) : null))
.filter(Boolean);
if (!members.length) return null;
return {
left: Math.min(...members.map((bounds) => bounds.left)) - this.padding,
top: Math.min(...members.map((bounds) => bounds.top)) - this.padding,
right: Math.max(...members.map((bounds) => bounds.right)) + this.padding,
bottom: Math.max(...members.map((bounds) => bounds.bottom)) + this.padding
};
}
/** Return the DOM frame, if the group currently has one. */
element() {
return this.elementValue;
}
/**
* Render or remove the generic frame according to membership and state.
* The engine calls this after component redraws; applications may call it
* directly after changing group presentation or membership.
*/
redraw() {
const surface = this.engine.surfaceElement();
const bounds = this.expanded && this.bounds();
if (!surface || !bounds) {
this.removeElement();
return this;
}
if (!this.elementValue) {
const frame = document.createElement("div");
frame.className = this.className;
frame.setAttribute("role", "group");
for (const side of ["top", "right", "bottom", "left"]) {
const dragEdge = document.createElement("div");
dragEdge.className = `${this.className}-drag-edge ${this.className}-drag-edge-${side}`;
if (this.onPointerDownHandler) dragEdge.addEventListener("pointerdown", this.onPointerDownHandler);
if (this.onDoubleClickHandler) dragEdge.addEventListener("dblclick", this.onDoubleClickHandler);
frame.append(dragEdge);
}
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = `${this.className}-toggle`;
toggle.addEventListener("click", () => {
this.setExpanded(!this.expanded);
if (this.onToggleHandler) this.onToggleHandler(this, this.expanded);
});
frame.append(toggle);
surface.prepend(frame);
this.elementValue = frame;
}
const toggle = this.elementValue.querySelector(`.${this.className}-toggle`);
if (toggle) {
toggle.textContent = this.expanded ? "-" : "+";
toggle.setAttribute("aria-expanded", String(this.expanded));
toggle.setAttribute("aria-label", this.expanded ? "Collapse group" : "Expand group");
}
this.elementValue.setAttribute("aria-label", this.label);
Object.assign(this.elementValue.style, {
left: `${bounds.left}px`,
top: `${bounds.top}px`,
width: `${bounds.right - bounds.left}px`,
height: `${bounds.bottom - bounds.top}px`,
zIndex: String(this.depth)
});
this.elementValue.style.setProperty("--rw-cmap-submap-background", this.backgroundColor);
this.elementValue.style.setProperty("--rw-cmap-submap-border", this.borderColor);
return this;
}
/** Remove the frame and all references owned by this group. */
destroy() {
for (const member of this.members) {
if (member instanceof DiagramGroup) member.destroy();
}
this.members.clear();
this.removeElement();
}
componentBounds(component) {
const attributes = component.attr();
const x = Number(attributes.x);
const y = Number(attributes.y);
const width = Number(attributes.width);
const height = Number(attributes.height);
if (![x, y, width, height].every(Number.isFinite)) return null;
return { left: x, top: y, right: x + width, bottom: y + height };
}
removeElement() {
if (this.elementValue) {
this.elementValue.remove();
this.elementValue = null;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import {
DiagramComponent,
pickAttributes,
validateOptionalHandler
} from "./diagram-component.js";
import { DrawingLink, DrawingSurface, DrawingTriple } from "./drawing-core.js";
import { DiagramNode } from "./diagram-node.js";
const LINK_ATTRIBUTES = [
"content",
"contentType",
"cx",
"cy",
"width",
"height",
"backgroundColor",
"borderColor",
"borderWidth",
"textColor",
"sourceX",
"sourceY",
"targetX",
"targetY",
"lineColor",
"lineWidth",
"hasArrow"
];
/**
* Public rendering handle for one diagram link.
*
* A link owns only visual and interaction state. Its source and target are
* DiagramNode instances from the same DiagramEngine.
*/
export class DiagramLink extends DiagramComponent {
constructor(engine, attributes) {
const component = new DrawingLink(pickAttributes(attributes, LINK_ATTRIBUTES));
super(engine, component, LINK_ATTRIBUTES);
}
/** Read or replace the source node. Pass null to disconnect it. */
sourceNode(node) {
return this.connectNode(DrawingSurface.CONNECTION_TYPE_SOURCE, node);
}
/** Read or replace the target node. Pass null to disconnect it. */
targetNode(node) {
return this.connectNode(DrawingSurface.CONNECTION_TYPE_TARGET, node);
}
/** Register a callback for a source or target connection change. */
onConnectionChange(handler) {
validateOptionalHandler(handler, "connection-change");
this.component.connectionChangeHandler = handler ? (type, node, event) => {
handler(this, type, node ? this.engine.handleFor(node) : null, event);
} : null;
return this;
}
/** Straighten both link segments between their current endpoints. */
straighten() {
const relation = this.component.relations()
.find((candidate) => candidate instanceof DrawingTriple);
const sourceNode = relation ? relation.sourceNode() : null;
const targetNode = relation ? relation.targetNode() : null;
if (!sourceNode || !targetNode) {
this.component.straighten();
return this;
}
const sourcePoint = relation.connectedPoint(
sourceNode, targetNode.cx(), targetNode.cy());
const targetPoint = relation.connectedPoint(
targetNode, sourceNode.cx(), sourceNode.cy());
this.component.straighten(
sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
return this;
}
/** Read or update one connected endpoint. */
connectNode(type, node) {
const connectedComponent = this.engine.drawingSurface
.connectedNode(type, this.component);
if (node === undefined) {
return connectedComponent ? this.engine.handleFor(connectedComponent) : null;
}
if (node !== null && !this.validateNode(type, node)) return this;
if (connectedComponent) {
this.engine.drawingSurface.disconnect(type, connectedComponent, this.component);
}
if (node !== null) {
this.engine.drawingSurface.connect(type, node.component, this.component);
}
return this;
}
/** Ensure an endpoint belongs to this engine and is not used at both ends. */
validateNode(type, node) {
if (!(node instanceof DiagramNode) || node.engine !== this.engine) {
throw new TypeError("Invalid diagram node");
}
const otherType = DrawingSurface.anotherConnectionType(type);
const otherNode = this.engine.drawingSurface.connectedNode(otherType, this.component);
if (otherNode === node.component) {
return false;
}
return true;
}
}
+48
View File
@@ -0,0 +1,48 @@
import {
DiagramComponent,
pickAttributes,
validateOptionalHandler
} from "./diagram-component.js";
import { DrawingNode } from "./drawing-core.js";
const NODE_ATTRIBUTES = [
"content",
"contentType",
"x",
"y",
"width",
"height",
"backgroundColor",
"borderColor",
"borderWidth",
"textColor"
];
/**
* Public rendering handle for one diagram node.
*
* DiagramEngine creates nodes and owns their lifetime. The editor uses this
* handle to update presentation attributes and receive completed moves.
*/
export class DiagramNode extends DiagramComponent {
constructor(engine, attributes) {
const component = new DrawingNode(pickAttributes(attributes, NODE_ATTRIBUTES));
super(engine, component, NODE_ATTRIBUTES);
}
/** Constrain or observe the node while it is being dragged. */
onMove(handler) {
validateOptionalHandler(handler, "move");
this.component.moveHandler = handler ?
(x, y) => handler(this, x, y) : null;
return this;
}
/** Register a callback for the end of a node drag gesture. */
onMoveEnd(handler) {
validateOptionalHandler(handler, "move-end");
this.component.moveEndHandler = handler ?
(x, y, event) => handler(this, x, y, event) : null;
return this;
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Maintain renderer component order and coordinate hit-test priority.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
import { Component, helper } from "./drawing-support.js";
class ComponentList extends helper.List {
constructor() {
super();
}
toFront(component) {
var data = this.data;
var index = data.indexOf(component);
if (index === -1)
return;
data.splice(index, 1);
data.push(component);
}
fromPoint(ctor, x, y) {
var data = this.data;
// The visual stack is connector controls, concepts and finally relations.
// Use that same priority for coordinate hit testing so a relation that is
// hidden behind a concept can never steal the concept's click.
var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
var tolerance = toleranceIndex === 0 ? 0 : 8;
for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
for (var i = data.length - 1; i >= 0; i--) {
var component = data[i];
if (!(component instanceof types[typeIndex]))
continue;
if (component.visible === false)
continue;
if (component.contains(x, y, tolerance))
return component;
}
}
}
return null;
}
}
class DisabledConnectorList extends helper.List {
constructor() {
super();
}
add(type, link) {
super.add( {
type: type,
link: link
});
}
remove(type, link) {
super.remove( {
type: type,
link: link
});
}
contains(type, link) {
return super.contains( {
type: type,
link: link
});
}
equal(a, b) {
return a.type === b.type && a.link === b.link;
}
}
export { ComponentList, DisabledConnectorList };
+524
View File
@@ -0,0 +1,524 @@
/**
* Render the node, link and connector primitives of a diagram.
*
* These classes contain geometry and DOM presentation only. Application code
* reaches them through DiagramNode and DiagramLink handles.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { Component, dom, helper } from "./drawing-support.js";
class Node extends Component {
constructor(props) {
super();
this.visible = true;
this.content = this.prop(props.content, '', helper.toString);
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
this.x = this.prop(props.x, 0, helper.toNumber);
this.y = this.prop(props.y, 0, helper.toNumber);
this.width = this.prop(props.width, 75, helper.toNumber);
this.height = this.prop(props.height, 30, helper.toNumber);
this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString);
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
this.textColor = this.prop(props.textColor, '#333', helper.toString);
this.zIndex = this.prop('auto');
this.element = this.prop(null);
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
this.moveHandler = null;
}
cx() {
return this.x() + this.width() / 2;
}
cy() {
return this.y() + this.height() / 2;
}
borderRadius() {
return 4;
}
contains(x, y, tolerance) {
var nx = this.x();
var ny = this.y();
var nwidth = this.width();
var nheight = this.height();
return (nx - tolerance <= x && x <= nx + nwidth + tolerance &&
ny - tolerance <= y && y <= ny + nheight + tolerance);
}
style() {
var contentType = this.contentType();
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)';
var borderWidthOffset = this.borderWidth() * 2;
return {
backgroundColor: this.backgroundColor(),
border: this.borderWidth() + 'px solid ' + this.borderColor(),
borderRadius: this.borderRadius() + 'px',
color: this.textColor(),
display: this.visible ? '' : 'none',
height: (this.height() - borderWidthOffset) + 'px',
lineHeight: (lineHeight - borderWidthOffset) + 'px',
msTransform: translate,
overflow: 'hidden',
pointerEvents: 'auto',
position: 'absolute',
textAlign: textAlign,
textOverflow: 'ellipsis',
transform: translate,
webkitTransform: translate,
whiteSpace: 'nowrap',
width: (this.width() - borderWidthOffset) + 'px',
zIndex: this.zIndex()
};
}
redraw() {
var element = this.element();
var parentElement = this.parentElement();
if (!parentElement && !element)
return;
// add element
if (parentElement && !element) {
element = dom.el('<div>');
this.element(element);
dom.append(parentElement, element);
this.redraw();
return;
}
// remove element
if (!parentElement && element) {
dom.remove(element);
this.element(null);
this.cache({});
return;
}
var cache = this.cache();
// update element
var content = this.content();
if (content !== cache.content) {
var contentType = this.contentType();
if (contentType === helper.CONTENT_TYPE_TEXT)
dom.text(element, content);
else if (contentType === helper.CONTENT_TYPE_HTML)
dom.html(element, content);
cache.content = content;
}
var style = this.style();
dom.css(element, helper.diffObj(style, cache.style));
cache.style = style;
this.notifyRendered();
}
}
class Link extends Component {
constructor(props) {
super();
this.visible = true;
this.content = this.prop(props.content, '', helper.toString);
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
this.cx = this.prop(props.cx, 100, helper.toNumber);
this.cy = this.prop(props.cy, 40, helper.toNumber);
this.width = this.prop(props.width, 50, helper.toNumber);
this.height = this.prop(props.height, 20, helper.toNumber);
this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString);
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
this.textColor = this.prop(props.textColor, '#333', helper.toString);
this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber);
this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber);
this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber);
this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber);
this.lineColor = this.prop(props.lineColor, '#333', helper.toString);
this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber);
this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean);
this.zIndex = this.prop('auto');
this.element = this.prop(null);
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
this.connectionChangeHandler = null;
}
straighten(sx, sy, tx, ty) {
if (arguments.length === 0) {
this.cx((this.sourceX() + this.targetX()) / 2);
this.cy((this.sourceY() + this.targetY()) / 2);
return;
}
this.cx((sx + tx) / 2);
this.cy((sy + ty) / 2);
this.sourceX(sx);
this.sourceY(sy);
this.targetX(tx);
this.targetY(ty);
}
contains(x, y, tolerance) {
var content = this.content();
var lcx = this.cx();
var lcy = this.cy();
// content area
if (content) {
var lwidth = this.width();
var lheight = this.height();
var lx = lcx - lwidth / 2;
var ly = lcy - lheight / 2;
if (lx - tolerance <= x && x <= lx + lwidth + tolerance &&
ly - tolerance <= y && y <= ly + lheight + tolerance) {
return true;
}
}
var lineWidth = this.lineWidth();
// source path
if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
return true;
// target path
if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
return true;
return false;
}
containsPath(x0, y0, x1, y1, x, y, d) {
var ax = x1 - x0;
var ay = y1 - y0;
var bx = x - x0;
var by = y - y0;
var r = (ax * bx + ay * by) / (ax * ax + ay * ay);
if (0 <= r && r <= 1) {
var px = x0 + r * ax;
var py = y0 + r * ay;
var dx = px - x;
var dy = py - y;
if (dx * dx + dy * dy <= d * d)
return true;
}
return false;
}
style() {
return {
display: this.visible ? '' : 'none',
pointerEvents: 'none',
position: 'absolute',
zIndex: this.zIndex()
};
}
pathContainerStyle() {
var width = Math.max(this.cx(), this.sourceX(), this.targetX());
var height = Math.max(this.cy(), this.sourceY(), this.targetY());
return {
height: height + 'px',
overflow: 'visible',
position: 'absolute',
width: width + 'px'
};
}
lineAttributes() {
var d = [
'M', this.sourceX(), this.sourceY(),
'L', this.cx(), this.cy(),
'L', this.targetX(), this.targetY()
].join(' ');
return {
d: d,
fill: 'none',
stroke: this.lineColor(),
'stroke-linecap': 'round',
'stroke-width': this.lineWidth()
};
}
arrowAttributes() {
var cx = this.cx();
var cy = this.cy();
var tx = this.targetX();
var ty = this.targetY();
var radians = Math.atan2(ty - cy, tx - cx);
var p0 = {
x: 15 * Math.cos(radians - 26 * Math.PI / 180),
y: 15 * Math.sin(radians - 26 * Math.PI / 180)
};
var p1 = {
x: 15 * Math.cos(radians + 26 * Math.PI / 180),
y: 15 * Math.sin(radians + 26 * Math.PI / 180)
};
var p2 = {
x: 7 * Math.cos(radians),
y: 7 * Math.sin(radians)
};
var d = [
'M', tx - p0.x, ty - p0.y,
'L', tx, ty,
'L', tx - p1.x, ty - p1.y,
'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y,
'Z'
].join(' ');
return {
d: d,
fill: this.lineColor(),
stroke: this.lineColor(),
'stroke-linejoin': 'round',
'stroke-width': this.lineWidth(),
visibility: this.hasArrow() ? 'visible' : 'hidden'
};
}
contentStyle() {
var contentType = this.contentType();
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
var x = this.cx() - this.width() / 2;
var y = this.cy() - this.height() / 2;
var translate = 'translate(' + x + 'px, ' + y + 'px)';
var borderWidthOffset = this.borderWidth() * 2;
return {
backgroundColor: this.backgroundColor(),
border: this.borderWidth() + 'px solid ' + this.borderColor(),
borderRadius: '4px',
color: this.textColor(),
height: (this.height() - borderWidthOffset) + 'px',
lineHeight: (lineHeight - borderWidthOffset) + 'px',
msTransform: translate,
overflow: 'hidden',
position: 'absolute',
textAlign: textAlign,
textOverflow: 'ellipsis',
transform: translate,
visibility: this.content() ? 'visible' : 'hidden',
webkitTransform: translate,
whiteSpace: 'nowrap',
width: (this.width() - borderWidthOffset) + 'px'
};
}
redraw() {
var element = this.element();
var parentElement = this.parentElement();
if (!parentElement && !element)
return;
// add element
if (parentElement && !element) {
element = dom.el('<div>');
dom.html(element, '<svg><path></path><path></path></svg><div></div>');
this.element(element);
dom.append(parentElement, element);
this.redraw();
return;
}
// remove element
if (!parentElement && element) {
dom.remove(element);
this.element(null);
this.cache({});
return;
}
var cache = this.cache();
// update path container element
var pathContainerStyle = this.pathContainerStyle();
var pathContainerElement = dom.child(element, 0);
dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle));
cache.pathContainerElementStyle = contentStyle;
// update line element
var lineAttributes = this.lineAttributes();
var lineElement = dom.child(pathContainerElement, 0);
dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes));
cache.lineAttributes = lineAttributes;
// update arrow element
var arrowAttributes = this.arrowAttributes();
var arrowElement = dom.child(pathContainerElement, 1);
dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes));
cache.arrowAttributes = arrowAttributes;
// update content element
var content = this.content();
var contentStyle = this.contentStyle();
var contentElement = dom.child(element, 1);
if (content !== cache.content) {
var contentType = this.contentType();
if (contentType === helper.CONTENT_TYPE_TEXT)
dom.text(contentElement, content);
else if (contentType === helper.CONTENT_TYPE_HTML)
dom.html(contentElement, content);
cache.content = content;
}
dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle));
cache.contentStyle = contentStyle;
// update container element
var style = this.style();
dom.css(element, helper.diffObj(style, cache.style));
cache.style = style;
this.notifyRendered();
}
}
class Connector extends Component {
constructor(props) {
super();
this.x = this.prop(props.x, 0, helper.toNumber);
this.y = this.prop(props.y, 0, helper.toNumber);
this.color = this.prop(Connector.COLOR_UNCONNECTED);
this.zIndex = this.prop('auto');
this.element = this.prop(null);
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
}
r() {
return 16;
}
contains(x, y, tolerance) {
var dx = x - this.x();
var dy = y - this.y();
var r = this.r() + tolerance;
return (dx * dx + dy * dy <= r * r);
}
style() {
var r = this.r();
var x = this.x() - r;
var y = this.y() - r;
var translate = 'translate(' + x + 'px, ' + y + 'px)';
return {
backgroundColor: this.color(),
border: '2px solid lightgray',
borderRadius: '50%',
boxSizing: 'border-box',
height: r * 2 + 'px',
msTransform: translate,
opacity: 0.6,
pointerEvents: 'none',
position: 'absolute',
transform: translate,
webkitTransform: translate,
width: r * 2 + 'px',
zIndex: this.zIndex()
};
}
redraw() {
var element = this.element();
var parentElement = this.parentElement();
if (!parentElement && !element)
return;
// add element
if (parentElement && !element) {
element = dom.el('<div>');
this.element(element);
dom.append(parentElement, element);
this.redraw();
return;
}
// remove element
if (!parentElement && element) {
dom.remove(element);
this.element(null);
return;
}
var cache = this.cache();
// update element
var style = this.style();
dom.css(element, helper.diffObj(style, cache.style));
cache.style = style;
this.notifyRendered();
}
}
Connector.COLOR_CONNECTED = 'lightgreen';
Connector.COLOR_UNCONNECTED = 'pink';
export { Connector, Link as DrawingLink, Node as DrawingNode };
+9
View File
@@ -0,0 +1,9 @@
/**
* Internal exports of the modular diagram rendering core.
*
* The implementation is derived from the MIT-licensed ionstage/cmap 0.1.3
* renderer and is maintained as part of racket-wiki.
*/
export { DrawingLink, DrawingNode } from "./drawing-components.js";
export { DrawingTriple } from "./drawing-relations.js";
export { DrawingSurface } from "./drawing-surface.js";
+308
View File
@@ -0,0 +1,308 @@
/**
* Keep diagram endpoints and connector controls geometrically related.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
class Relation {
constructor() {
}
prop(initialValue) {
var cache = initialValue;
return function(value) {
if (typeof value === 'undefined')
return cache;
cache = value;
};
}
update() {
}
}
class Triple extends Relation {
constructor(props) {
super();
this.link = this.prop(props.link);
this.sourceNode = this.prop(props.sourceNode || null);
this.targetNode = this.prop(props.targetNode || null);
this.skipNextUpdate = this.prop(false);
this.nodePositionsCache = this.prop({});
}
update(changedComponent) {
if (this.skipNextUpdate()) {
this.skipNextUpdate(false);
return;
}
var link = this.link();
var sourceNode = this.sourceNode();
var targetNode = this.targetNode();
if (changedComponent instanceof Node)
this.updateNode(link, sourceNode, targetNode, changedComponent);
else if (changedComponent instanceof Link)
this.updateLink(link, sourceNode, targetNode);
}
updateNode(link, sourceNode, targetNode, changedNode) {
if (sourceNode && targetNode)
this.rotateLink(link, sourceNode, targetNode, changedNode);
else
this.shiftLink(link, sourceNode, targetNode, changedNode);
this.updateNodePositionsCache();
}
rotateLink(link, sourceNode, targetNode, changedNode) {
var cache = this.nodePositionsCache();
var sncx = cache.sncx;
var sncy = cache.sncy;
var tncx = cache.tncx;
var tncy = cache.tncy;
var lcx = link.cx();
var lcy = link.cy();
var ts_dx = tncx - sncx;
var ts_dy = tncy - sncy;
var cs_dx = lcx - sncx;
var cs_dy = lcy - sncy;
var ts_rad0 = Math.atan2(ts_dy, ts_dx);
var cs_rad0 = Math.atan2(cs_dy, cs_dx);
// changed node position
if (changedNode === sourceNode) {
sncx = sourceNode.cx();
sncy = sourceNode.cy();
} else if (changedNode === targetNode) {
tncx = targetNode.cx();
tncy = targetNode.cy();
}
// center positions of two nodes are equal
if (cs_rad0 === 0) {
link.cx((sncx + tncx) / 2);
link.cy((sncy + tncy) / 2);
return;
}
var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy);
var ts_cs_rad = ts_rad0 - cs_rad0;
ts_dx = tncx - sncx;
ts_dy = tncy - sncy;
var ts_rad1 = Math.atan2(ts_dy, ts_dx);
var cs_rad1 = ts_rad1 - ts_cs_rad;
var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1;
var cs_d1 = cs_d0 * d_rate;
lcx = sncx + cs_d1 * Math.cos(cs_rad1);
lcy = sncy + cs_d1 * Math.sin(cs_rad1);
link.cx(lcx);
link.cy(lcy);
}
shiftLink(link, sourceNode, targetNode, changedNode) {
var cache = this.nodePositionsCache();
var ncx = changedNode.cx();
var ncy = changedNode.cy();
if (changedNode === sourceNode) {
link.targetX(link.targetX() + (ncx - cache.sncx));
link.targetY(link.targetY() + (ncy - cache.sncy));
} else if (changedNode === targetNode) {
link.sourceX(link.sourceX() + (ncx - cache.tncx));
link.sourceY(link.sourceY() + (ncy - cache.tncy));
}
}
updateLink(link, sourceNode, targetNode) {
var lx, ly, p;
if (sourceNode) {
// connect link to source node
lx = targetNode ? link.cx() : link.targetX();
ly = targetNode ? link.cy() : link.targetY();
p = this.connectedPoint(sourceNode, lx, ly);
link.sourceX(p.x);
link.sourceY(p.y);
}
if (targetNode) {
// connect link to target node
lx = sourceNode ? link.cx() : link.sourceX();
ly = sourceNode ? link.cy() : link.sourceY();
p = this.connectedPoint(targetNode, lx, ly);
link.targetX(p.x);
link.targetY(p.y);
}
if (!sourceNode || !targetNode) {
// link content moves to midpoint
link.cx((link.sourceX() + link.targetX()) / 2);
link.cy((link.sourceY() + link.targetY()) / 2);
}
}
updateLinkAngle(radians) {
var link = this.link();
var sourceNode = this.sourceNode();
var targetNode = this.targetNode();
var ldx = link.targetX() - link.sourceX();
var ldy = link.targetY() - link.sourceY();
var d = Math.sqrt(ldx * ldx + ldy * ldy);
var connectedNode = sourceNode || targetNode;
var cx = connectedNode.cx();
var cy = connectedNode.cy();
var lx = cx + d * Math.cos(radians);
var ly = cy + d * Math.sin(radians);
var p = this.connectedPoint(connectedNode, lx, ly);
if (connectedNode === sourceNode)
link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy);
else if (connectedNode === targetNode)
link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y);
}
updateNodePositionsCache() {
var sourceNode = this.sourceNode();
var targetNode = this.targetNode();
var cache = this.nodePositionsCache();
if (sourceNode) {
cache.sncx = sourceNode.cx();
cache.sncy = sourceNode.cy();
}
if (targetNode) {
cache.tncx = targetNode.cx();
cache.tncy = targetNode.cy();
}
}
connectedPoint(node, lx, ly) {
var nx = node.x();
var ny = node.y();
var nwidth = node.width();
var nheight = node.height();
var ncx = node.cx();
var ncy = node.cy();
var alpha = Math.atan2(ly - ncy, lx - ncx);
var beta = Math.PI / 2 - alpha;
var t = Math.atan2(nheight, nwidth);
var x, y;
// left edge
if (alpha < t - Math.PI || alpha > Math.PI - t) {
x = nx;
y = ncy - nwidth * Math.tan(alpha) / 2;
}
// top edge
else if (alpha < -t) {
x = ncx - nheight * Math.tan(beta) / 2;
y = ny;
}
// right edge
else if (alpha < t) {
x = nx + nwidth;
y = ncy + nwidth * Math.tan(alpha) / 2;
}
// bottom edge
else {
x = ncx + nheight * Math.tan(beta) / 2;
y = ny + nheight;
}
var x0, y0, l, ex, ey;
var r = node.borderRadius();
var atCorner = false;
// top-left corner
if (x < nx + r && y < ny + r) {
x0 = nx + r;
y0 = ny + r;
atCorner = true;
}
// top-right corner
else if (x > nx + nwidth - r && y < ny + r) {
x0 = nx + nwidth - r;
y0 = ny + r;
atCorner = true;
}
// bottom-left corner
else if (x < nx + r && y > ny + nheight - r) {
x0 = nx + r;
y0 = ny + nheight - r;
atCorner = true;
}
// bottom-right corner
else if (x > nx + nwidth - r && y > ny + nheight - r) {
x0 = nx + nwidth - r;
y0 = ny + nheight - r;
atCorner = true;
}
if (atCorner) {
l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y));
ex = (x0 - x) / l;
ey = (y0 - y) / l;
x = x0 - r * ex;
y = y0 - r * ey;
}
return {
x: x,
y: y
};
}
}
class LinkConnectorRelation extends Relation {
constructor(props) {
super();
this.type = this.prop(props.type);
this.link = this.prop(props.link);
this.connector = this.prop(props.connector);
}
isConnected(isConnected) {
var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED;
this.connector().color(color);
}
update(changedComponent) {
var type = this.type();
var link = this.link();
var connector = this.connector();
if (changedComponent === link) {
connector.x(link[type + 'X']());
connector.y(link[type + 'Y']());
}
}
}
export { LinkConnectorRelation, Triple as DrawingTriple };
+340
View File
@@ -0,0 +1,340 @@
/**
* Shared DOM and state support for the low-level diagram renderer.
*
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
const CONTENT_TYPE_TEXT = 'text';
const CONTENT_TYPE_HTML = 'html';
class ItemList {
constructor() {
this.data = [];
}
add(item) {
if (!this.contains(item)) this.data.push(item);
}
remove(item) {
for (let index = this.data.length - 1; index >= 0; index -= 1) {
if (this.equal(this.data[index], item)) {
this.data.splice(index, 1);
break;
}
}
}
contains(item) {
return this.data.some((candidate) => this.equal(candidate, item));
}
equal(first, second) {
return first === second;
}
toArray() {
return this.data.slice();
}
}
const helper = {
CONTENT_TYPE_HTML,
CONTENT_TYPE_TEXT,
List: ItemList,
toNumber(value, defaultValue) {
return !isNaN(value) ? Number(value) : defaultValue;
},
toString(value, defaultValue) {
return value !== undefined ? String(value) : defaultValue;
},
toBoolean(value, defaultValue) {
return value !== undefined ? Boolean(value) : defaultValue;
},
toContentType(value, defaultValue) {
if (value === CONTENT_TYPE_TEXT || value === CONTENT_TYPE_HTML) return value;
return defaultValue;
},
eachInstance(values, constructor, callback) {
values.filter((value) => value instanceof constructor).forEach(callback);
},
firstInstance(values, constructor) {
return values.find((value) => value instanceof constructor);
},
diffObj(newObject, oldObject) {
const difference = {};
for (const key in newObject) {
if (!oldObject || newObject[key] !== oldObject[key]) {
difference[key] = newObject[key];
}
}
return difference;
},
identity(value) {
return value;
}
};
var dom = {};
dom.disabled = function() {
return (typeof document === 'undefined');
};
dom.el = function(selector) {
if (selector.charAt(0) === '<') {
selector = selector.match(/<(.+)>/)[1];
return document.createElement(selector);
}
};
dom.body = function() {
return document.body;
};
dom.attr = function(el, props) {
for (var key in props) {
el.setAttribute(key, props[key]);
}
};
dom.css = function(el, props) {
var style = el.style;
for (var key in props) {
style[key] = props[key];
}
};
dom.rect = function(el) {
return el.getBoundingClientRect();
};
dom.clientWidth = function(el) {
return el.clientWidth;
};
dom.clientHeight = function(el) {
return el.clientHeight;
};
dom.scrollLeft = function(el) {
return el.scrollLeft;
};
dom.scrollTop = function(el) {
return el.scrollTop;
};
dom.scrollWidth = function(el) {
return el.scrollWidth;
};
dom.scrollHeight = function(el) {
return el.scrollHeight;
};
dom.text = function(el, s) {
el.textContent = s;
};
dom.html = function(el, s) {
el.innerHTML = s;
};
dom.append = function(parent, el) {
parent.appendChild(el);
};
dom.remove = function(el) {
el.parentNode.removeChild(el);
};
dom.child = function(el, index) {
return el.childNodes[index];
};
dom.animate = function(callback) {
return window.requestAnimationFrame(callback);
};
dom.supportsTouch = function() {
return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch));
};
dom.on = function(el, type, listener) {
el.addEventListener(type, listener);
};
dom.off = function(el, type, listener) {
el.removeEventListener(type, listener);
};
dom.pagePoint = function(event, offset) {
if (dom.supportsTouch())
event = event.changedTouches[0];
return {
x: event.pageX - (offset ? offset.x : 0),
y: event.pageY - (offset ? offset.y : 0)
};
};
dom.clientPoint = function(event, offset) {
if (dom.supportsTouch())
event = event.changedTouches[0];
return {
x: event.clientX - (offset ? offset.x : 0),
y: event.clientY - (offset ? offset.y : 0)
};
};
dom.cancel = function(event) {
event.preventDefault();
};
class Draggable {
constructor(element, onStart, onMove, onEnd) {
this.element = element;
this.onStart = onStart;
this.onMove = onMove;
this.onEnd = onEnd;
this.start = this.start.bind(this);
this.move = this.move.bind(this);
this.end = this.end.bind(this);
this.locked = false;
this.startingPoint = null;
this.startEvent = dom.supportsTouch() ? 'touchstart' : 'mousedown';
this.moveEvent = dom.supportsTouch() ? 'touchmove' : 'mousemove';
this.endEvent = dom.supportsTouch() ? 'touchend' : 'mouseup';
dom.on(this.element, this.startEvent, this.start);
}
start(event) {
if (this.locked)
return;
this.locked = true;
this.startingPoint = dom.pagePoint(event);
const rectangle = dom.rect(this.element);
const point = dom.clientPoint(event, {
x: rectangle.left - dom.scrollLeft(this.element),
y: rectangle.top - dom.scrollTop(this.element)
});
if (typeof this.onStart === 'function') this.onStart(point.x, point.y, event);
dom.on(document, this.moveEvent, this.move);
dom.on(document, this.endEvent, this.end);
}
move(event) {
const distance = dom.pagePoint(event, this.startingPoint);
if (typeof this.onMove === 'function') this.onMove(distance.x, distance.y, event);
}
end(event) {
dom.off(document, this.moveEvent, this.move);
dom.off(document, this.endEvent, this.end);
const distance = dom.pagePoint(event, this.startingPoint);
if (typeof this.onEnd === 'function') this.onEnd(distance.x, distance.y, event);
this.locked = false;
}
}
dom.draggable = function(element, onStart, onMove, onEnd) {
if (dom.disabled()) return null;
return new Draggable(element, onStart, onMove, onEnd);
};
const dirtyComponents = [];
let renderRequestId = null;
class Component {
constructor() {
this.disposed = false;
}
dispose() {
this.disposed = true;
}
prop(initialValue, defaultValue, converter) {
const convert = typeof converter === 'function' ? converter : helper.identity;
let cache = convert(initialValue, defaultValue);
return (value) => {
if (typeof value === 'undefined')
return cache;
if (value === cache)
return;
cache = convert(value, cache);
this.markDirty();
};
}
relations() {
return [];
}
redraw() {}
notifyRendered() {
if (typeof this.renderedHandler === 'function' && this.element())
this.renderedHandler(this.element());
}
markDirty() {
if (dom.disabled() || this.disposed)
return;
if (!dirtyComponents.includes(this))
dirtyComponents.push(this);
if (renderRequestId !== null)
return;
renderRequestId = dom.animate(redrawDirtyComponents);
}
}
function updateDirtyRelations(index) {
const initialLength = dirtyComponents.length;
for (let position = index; position < initialLength; position += 1) {
const component = dirtyComponents[position];
if (component.disposed)
continue;
component.relations().forEach((relation) => {
if (!relation.disposed)
relation.update(component);
});
}
if (dirtyComponents.length > initialLength)
updateDirtyRelations(initialLength);
}
function redrawDirtyComponents() {
updateDirtyRelations(0);
dirtyComponents.forEach((component) => {
if (!component.disposed)
component.redraw();
});
dirtyComponents.length = 0;
renderRequestId = null;
}
export { Component, dom, helper };
+615
View File
@@ -0,0 +1,615 @@
/**
* Own the diagram surface, component relations and pointer interaction.
*
* DrawingSurface is internal to DiagramEngine. It coordinates the primitive
* renderers, performs hit testing and translates drag gestures to geometry.
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
*/
import { ComponentList, DisabledConnectorList } from "./drawing-collections.js";
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
import { DrawingTriple as Triple, LinkConnectorRelation } from "./drawing-relations.js";
import { Component, dom, helper } from "./drawing-support.js";
class Cmap extends Component {
constructor(rootElement) {
super();
this.componentList = this.prop(new ComponentList());
this.disabledConnectorList = this.prop(new DisabledConnectorList());
this.dragDisabledComponentList = this.prop(new ComponentList());
this.element = this.prop(null);
this.rootElement = this.prop(rootElement || null);
this.retainerElement = this.prop(null);
this.dragContext = this.prop({});
this.selectionHandler = null;
this.activationHandler = null;
this.lastClickComponent = null;
this.lastClickTime = 0;
this.zoomFactor = 1;
this.markDirty();
}
add(component) {
component.parentElement(this.element());
this.componentList().add(component);
this.updateZIndex();
}
static anotherConnectionType(type) {
if (type === Cmap.CONNECTION_TYPE_SOURCE)
return Cmap.CONNECTION_TYPE_TARGET;
else if (type === Cmap.CONNECTION_TYPE_TARGET)
return Cmap.CONNECTION_TYPE_SOURCE;
}
remove(component) {
component.parentElement(null);
if (component instanceof Link)
this.hideConnectors(component);
this.disconnect(component);
this.componentList().remove(component);
this.updateZIndex();
}
toFront(component) {
this.componentList().toFront(component);
this.updateZIndex();
}
updateZIndex() {
var linkIndex = 0;
var nodeIndex = 0;
this.componentList().toArray().forEach(function(component) {
if (component instanceof Connector)
return;
// Relations always occupy a lower band than concept nodes. Reordering a
// selected component therefore only changes its order inside that band.
var zIndex = component instanceof Link ?
Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
component.zIndex(zIndex);
if (!(component instanceof Link))
return;
// update connector z-index of link
helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
});
});
}
connect(type, node, link) {
var linkRelations = link.relations();
var triple = helper.firstInstance(linkRelations, Triple);
var nodeKey = type + 'Node';
if (triple && triple[nodeKey]())
throw new Error('Already connected');
var anotherType = Cmap.anotherConnectionType(type);
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
if (anotherSideNode === node)
throw new Error('Already connected to the ' + anotherType + ' of the link');
if (triple) {
triple[nodeKey](node);
} else {
var tripleProps = {};
tripleProps.link = link;
tripleProps[nodeKey] = node;
triple = new Triple(tripleProps);
// add triple to the beginning of link relations to be ahead of link-connector relation
// connector position won't be updated before triple update
linkRelations.unshift(triple);
}
// add triple to node
node.relations().push(triple);
triple.updateNodePositionsCache();
// update connectors of link
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
if (relation.type() === type)
relation.isConnected(true);
});
// link content moves to midpoint of connected nodes
if (anotherSideNode) {
link.cx((node.cx() + anotherSideNode.cx()) / 2);
link.cy((node.cy() + anotherSideNode.cy()) / 2);
}
// do not need to mark node dirty (stay unchanged)
link.markDirty();
}
disconnect(type, node, link) {
if (type instanceof Component) {
var component = type;
var relations = component.relations().slice();
// disconnect all connections of component
helper.eachInstance(relations, Triple, function(triple) {
var link = triple.link();
var sourceNode = triple.sourceNode();
var targetNode = triple.targetNode();
if (sourceNode && (component === link || component === sourceNode))
this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link);
if (targetNode && (component === link || component === targetNode))
this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link);
}.bind(this));
return;
}
var linkRelations = link.relations();
var triple = helper.firstInstance(linkRelations, Triple);
var nodeKey = type + 'Node';
if (!triple || triple[nodeKey]() !== node)
throw new Error('Not connected');
triple[nodeKey](null);
// remove triple from node
var nodeRelations = node.relations();
nodeRelations.splice(nodeRelations.indexOf(triple), 1);
// remove triple from link
if (!triple.sourceNode() && !triple.targetNode())
linkRelations.splice(linkRelations.indexOf(triple), 1);
// update connectors of link
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
if (relation.type() === type)
relation.isConnected(false);
});
// do not need to mark node dirty (stay unchanged)
link.markDirty();
}
connectedNode(type, link) {
var triple = helper.firstInstance(link.relations(), Triple);
if (!triple)
return null;
return triple[type + 'Node']();
}
showConnector(type, link) {
if (this.connectorVisible(type, link))
return;
var disabledConnectorList = this.disabledConnectorList();
var connectorDisabled = disabledConnectorList.contains(type, link);
if (!connectorDisabled)
this.addConnector(type, link);
}
connectorVisible(type, link) {
return link.relations().some(function(relation) {
return relation instanceof LinkConnectorRelation && relation.type() === type;
});
}
addConnector(type, link) {
var connector = new Connector({
x: link[type + 'X'](),
y: link[type + 'Y']()
});
var linkConnectorRelation = new LinkConnectorRelation({
type: type,
link: link,
connector: connector
});
var linkRelations = link.relations();
var triple = helper.firstInstance(linkRelations, Triple);
var isConnected = (triple && !!triple[type + 'Node']());
linkConnectorRelation.isConnected(isConnected);
linkRelations.push(linkConnectorRelation);
connector.relations().push(linkConnectorRelation);
this.add(connector);
}
hideConnector(type, link) {
var linkRelations = link.relations();
for (var i = linkRelations.length - 1; i >= 0; i--) {
var relation = linkRelations[i];
if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type)
continue;
// remove connector component
this.remove(relation.connector());
// remove link-connector relation from link
linkRelations.splice(i, 1);
break;
}
}
showConnectors(link) {
this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link);
}
hideConnectors(link) {
this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link);
}
hideAllConnectors() {
this.componentList().toArray().forEach(function(component) {
if (component instanceof Link)
this.hideConnectors(component);
}.bind(this));
}
enableConnector(type, link) {
this.disabledConnectorList().remove(type, link);
}
disableConnector(type, link) {
// remove showing connector
this.hideConnector(type, link);
this.disabledConnectorList().add(type, link);
}
connectorEnabled(type, link) {
return !this.disabledConnectorList().contains(type, link);
}
enableDrag(component) {
this.dragDisabledComponentList().remove(component);
}
disableDrag(component) {
this.dragDisabledComponentList().add(component);
}
dragEnabled(component) {
return !this.dragDisabledComponentList().contains(component);
}
onstart(x, y, event) {
var context = this.dragContext();
var component = this.componentList().fromPoint(Component, x, y);
context.component = component;
if (typeof this.selectionHandler === 'function')
this.selectionHandler(component, event);
if (!(component instanceof Connector))
this.hideAllConnectors();
if (!component)
return;
var draggable = !this.dragDisabledComponentList().contains(component);
context.draggable = draggable;
if (!draggable)
return;
dom.cancel(event);
this.toFront(component);
if (component instanceof Node) {
context.x = component.x();
context.y = component.y();
} else if (component instanceof Link) {
context.cx = component.cx();
context.cy = component.cy();
context.sourceX = component.sourceX();
context.sourceY = component.sourceY();
context.targetX = component.targetX();
context.targetY = component.targetY();
context.triple = helper.firstInstance(component.relations(), Triple);
this.showConnectors(component);
} else if (component instanceof Connector) {
var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation);
context.x = x;
context.y = y;
context.type = linkConnectorRelation.type();
context.link = linkConnectorRelation.link();
}
this.fixScrollSize();
}
onmove(dx, dy, event) {
var context = this.dragContext();
var component = context.component;
if (!component)
return;
if (!context.draggable)
return;
if (component instanceof Node) {
var nodeX = context.x + dx;
var nodeY = context.y + dy;
if (typeof component.moveHandler === 'function') {
var constrainedPosition = component.moveHandler(nodeX, nodeY);
if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) {
nodeX = constrainedPosition.x;
nodeY = constrainedPosition.y;
}
}
component.x(nodeX);
component.y(nodeY);
} else if (component instanceof Link) {
var cx = context.cx + dx;
var cy = context.cy + dy;
var triple = context.triple;
var connectedNode = null;
if (triple) {
var sourceNode = triple.sourceNode();
var targetNode = triple.targetNode();
if (sourceNode && !targetNode)
connectedNode = sourceNode;
else if (!sourceNode && targetNode)
connectedNode = targetNode;
}
if (connectedNode) {
// only one node connected
var x = cx - connectedNode.cx();
var y = cy - connectedNode.cy();
triple.updateLinkAngle(Math.atan2(y, x));
triple.skipNextUpdate(true);
} else if (!triple || component.content()) {
// not connected or link has content
// (except two nodes connected but link has no content)
component.cx(cx);
component.cy(cy);
component.sourceX(context.sourceX + dx);
component.sourceY(context.sourceY + dy);
component.targetX(context.targetX + dx);
component.targetY(context.targetY + dy);
}
} else if (component instanceof Connector) {
var x = context.x + dx;
var y = context.y + dy;
var type = context.type;
var link = context.link;
var triple = helper.firstInstance(link.relations(), Triple);
var connectedNode = triple ? triple[type + 'Node']() : null;
var node = this.componentList().fromPoint(Node, x, y);
if (connectedNode && connectedNode === node) {
// already connected (do nothing)
return;
}
var anotherType = Cmap.anotherConnectionType(type);
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
if (connectedNode && connectedNode !== node) {
this.disconnect(type, connectedNode, link);
connectedNode = null;
}
var needsConnect = !connectedNode && node && anotherSideNode !== node;
if (needsConnect) {
if (anotherSideNode) {
var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy());
link[type + 'X'](p.x);
link[type + 'Y'](p.y);
triple.update(link);
triple.skipNextUpdate(true);
}
this.connect(type, node, link);
} else {
link[type + 'X'](x);
link[type + 'Y'](y);
if (!anotherSideNode)
link.straighten();
}
}
}
onend(dx, dy, event) {
var context = this.dragContext();
var component = context.component;
if (!component) {
this.lastClickComponent = null;
this.lastClickTime = 0;
return;
}
if (!context.draggable) {
this.lastClickComponent = null;
this.lastClickTime = 0;
return;
}
// dx/dy are logical map coordinates; keep the click tolerance at four
// physical screen pixels at every zoom level.
var clickTolerance = 4 / this.zoomFactor;
var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance;
if (isClick) {
var now = Date.now();
var isDoubleClick = (component === this.lastClickComponent &&
now - this.lastClickTime <= 500);
if (isDoubleClick) {
this.lastClickComponent = null;
this.lastClickTime = 0;
if (typeof this.activationHandler === 'function')
this.activationHandler(component, event);
} else {
this.lastClickComponent = component;
this.lastClickTime = now;
}
} else {
this.lastClickComponent = null;
this.lastClickTime = 0;
}
if (component instanceof Node && typeof component.moveEndHandler === 'function')
component.moveEndHandler(component.x(), component.y(), event);
if (component instanceof Connector) {
var link = context.link;
var triple = helper.firstInstance(link.relations(), Triple);
var connectedNode = triple ? triple[context.type + 'Node']() : null;
if (typeof link.connectionChangeHandler === 'function')
link.connectionChangeHandler(context.type, connectedNode, event);
}
this.unfixScrollSize();
}
fixScrollSize() {
var element = this.element();
var clientWidth = dom.clientWidth(element);
var clientHeight = dom.clientHeight(element);
var scrollWidth = dom.scrollWidth(element);
var scrollHeight = dom.scrollHeight(element);
// check if scrolled
if (clientWidth === scrollWidth && clientHeight === scrollHeight)
return;
var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)';
dom.css(this.retainerElement(), {
msTransform: translate,
transform: translate,
webkitTransform: translate
});
}
unfixScrollSize() {
var translate = 'translate(-1px, -1px)';
dom.css(this.retainerElement(), {
msTransform: translate,
transform: translate,
webkitTransform: translate
});
}
style() {
return {
color: '#333',
cursor: 'default',
fontFamily: 'sans-serif',
fontSize: '14px',
height: '100%',
MozUserSelect: 'none',
msUserSelect: 'none',
overflow: 'visible',
position: 'relative',
userSelect: 'none',
webkitUserSelect: 'none',
width: '100%',
zoom: this.zoomFactor
};
}
retainerStyle() {
return {
height: '1px',
pointerEvents: 'none',
position: 'absolute',
width: '1px'
};
}
redraw() {
if (this.disposed)
return;
var rootElement = this.rootElement();
if (!rootElement) {
rootElement = dom.body();
dom.css(rootElement, {
height: '100vh',
margin: '0',
width: '100vw'
});
this.rootElement(rootElement);
}
var previousElement = this.element();
var element = dom.el('<div>');
element.className = 'rw-cmap-surface';
dom.draggable(element, function(x, y, event) {
this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
}.bind(this), function(dx, dy, event) {
this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
}.bind(this), function(dx, dy, event) {
this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
}.bind(this));
this.element(element);
this.componentList().toArray().forEach(function(component) {
component.parentElement(element);
});
var retainerElement = dom.el('<div>');
dom.css(retainerElement, this.retainerStyle());
dom.append(element, retainerElement);
this.retainerElement(retainerElement);
// set initial position of retainer
this.unfixScrollSize();
dom.css(element, this.style());
if (previousElement && previousElement.parentNode)
previousElement.parentNode.removeChild(previousElement);
dom.append(rootElement, element);
}
}
Cmap.LINK_Z_INDEX_BASE = 100;
Cmap.NODE_Z_INDEX_BASE = 100000;
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
Cmap.CONNECTION_TYPE_SOURCE = 'source';
Cmap.CONNECTION_TYPE_TARGET = 'target';
export { Cmap as DrawingSurface };
+89 -1
View File
@@ -1,4 +1,13 @@
/* Versioned JSON interchange for the Racket Wiki CMap model. */
/**
* Versioned JSON interchange for the Racket Wiki CMap model.
*
* The bundle separates shared concept content from map-local placement data.
* `buildBundle` follows map and page references, embeds the referenced page
* attachments, and validates the resulting object. Import code calls
* `validateBundle` first and then uses `preparedMapDocument` to join shared
* concepts back into a map document. The helpers in this module deliberately
* return detached JSON values so callers cannot mutate an input map or bundle.
*/
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
@@ -12,10 +21,15 @@
];
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
/** Return a detached JSON-compatible copy while preserving undefined. */
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/**
* Check a wiki page reference in either `slug` or `namespace:slug` form.
* The check is shared by concept links, map metadata and exported pages.
*/
function validPageReference(value) {
if (typeof value !== "string") return false;
const separator = value.indexOf(":");
@@ -25,6 +39,7 @@
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
}
/** Accept only complete HTTP(S) URLs for external concept links. */
function validExternalUrl(value) {
try {
const url = new URL(String(value));
@@ -34,6 +49,11 @@
}
}
/**
* Decode the one or two historical JSON string wrappers used by storage.
* Invalid or non-object values become an empty document; bundle validation
* remains responsible for rejecting malformed imported data.
*/
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
@@ -43,6 +63,7 @@
documentValue : {};
}
/** Copy only shared concept fields into the bundle-level concept table. */
function conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
@@ -51,18 +72,21 @@
return result;
}
/** Index valid document concepts by their stable identity. */
function conceptsById(documentValue) {
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
.map((concept) => [concept.id, concept]));
}
/** Return unique concept ids used by non-phrase placements. */
function itemConceptIds(documentValue) {
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
.map((item) => item.conceptId))];
}
/** Find linked CMap slugs in the concepts actually placed on a document. */
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
@@ -70,6 +94,11 @@
.filter(Boolean))];
}
/**
* Collect page references from map metadata and placed concepts.
* Unplaced concept records are intentionally ignored because they are not
* part of the visible map dependency graph.
*/
function linkedPageReferences(documentValue) {
const references = new Set();
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
@@ -87,6 +116,11 @@
return references;
}
/**
* Extract local upload URLs from Markdown and HTML-like markup.
* A Set removes duplicates; the final prefix check removes a shorter URL
* accidentally captured from a URL containing a space.
*/
function attachmentUrls(markdown) {
const source = String(markdown || "");
const urls = new Set();
@@ -102,6 +136,7 @@
other !== url && other.startsWith(`${url} `)));
}
/** Derive a readable fallback filename from an upload URL. */
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
@@ -111,6 +146,7 @@
}
}
/** Replace upload URLs after imported attachments receive new server URLs. */
function replaceAttachmentUrls(markdown, replacements) {
let result = String(markdown || "");
const entries = replacements instanceof Map ? [...replacements.entries()] :
@@ -123,6 +159,11 @@
return result;
}
/**
* Strip shared concept fields from placements while preserving layout data.
* It also keeps only connectors whose two local item endpoints still exist,
* because export must not emit dangling layout relations.
*/
function placementDocument(documentValue) {
const documentCopy = clone(decodedDocument(documentValue));
const ids = itemConceptIds(documentCopy);
@@ -149,6 +190,10 @@
return documentCopy;
}
/**
* Convert one wiki page and all uploads referenced by its Markdown into a
* bundle page record. The attachment loader is called once per unique URL.
*/
async function pageRecord(page, requestedReference, loadAttachment) {
const markdown = String(page.markdown || "");
const attachments = [];
@@ -176,6 +221,24 @@
};
}
/**
* Build and validate a complete export bundle.
*
* @param {object} options Export options and asynchronous repository loaders.
* @param {object} options.rootMap Root map with `slug`, `title` and `document`.
* @param {Function} options.loadConceptMap Loads a linked map by slug.
* @param {Function} options.loadWikiPage Loads a linked wiki page by reference.
* @param {Function} [options.loadAttachment] Loads base64 upload content.
* @param {number} [options.maxDepth=0] Maximum depth for linked CMaps.
* @returns {Promise<object>} A validated, detached interchange bundle.
* @throws {Error} When required loaders are absent or a loaded record is invalid.
* @sideeffects Calls the supplied map, page and attachment loaders.
*
* `collectMap` traverses map dependencies and uses `placementDocument` for
* layout-only map records. It collects shared concepts and page references
* separately; page records are then built and the final aggregate is checked
* by `validateBundle` before it is returned.
*/
async function buildBundle(options) {
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
@@ -248,6 +311,18 @@
return bundle;
}
/**
* Validate the complete bundle contract and return the original bundle.
*
* @param {object} bundle Candidate bundle to validate.
* @returns {object} The same bundle object after successful validation.
* @throws {Error} With `validationErrors` when one or more contract checks fail.
*
* Validation also builds the concept, map, item and page-reference indexes
* needed to detect duplicate identities, dangling references and missing
* linked pages. Up to twenty errors are included in the message while the
* complete list remains available on `error.validationErrors`.
*/
function validateBundle(bundle) {
const errors = [];
const issue = (path, message) => errors.push(`${path}: ${message}`);
@@ -260,6 +335,7 @@
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
// Validate the shared concept table and collect its page dependencies.
const conceptIds = new Set();
const conceptLabels = new Set();
const usedConceptIds = new Set();
@@ -297,6 +373,7 @@
}
}
// Validate map identities, placements, connectors and map-level page links.
const mapSlugs = new Set();
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
const path = `cmaps[${mapIndex}]`;
@@ -362,6 +439,7 @@
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
}
// Validate page records and their embedded attachment payloads.
const pageReferences = new Set();
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
const path = `pages[${index}]`;
@@ -405,6 +483,7 @@
}
}
}
// Reconcile all collected page dependencies with present or declared-missing pages.
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
for (const reference of linkedPageReferences) {
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
@@ -412,6 +491,7 @@
}
}
// Report all discovered issues together so import callers can repair a bundle in one pass.
if (errors.length) {
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
error.validationErrors = errors;
@@ -420,6 +500,14 @@
return bundle;
}
/**
* Rejoin bundle-level concept content with one placement-only map document.
*
* @param {object} bundle A bundle that satisfies `validateBundle`.
* @param {object} cmap One entry from `bundle.cmaps`.
* @returns {object} A detached document suitable for repository import.
* @throws {Error} When the bundle is invalid or a referenced concept is absent.
*/
function preparedMapDocument(bundle, cmap) {
validateBundle(bundle);
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
+546
View File
@@ -0,0 +1,546 @@
"use strict";
import { debug, elementDescription, selectionStyle, escapeHtml } from "../cmap-utils.js";
export class CmapItemDecorator {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get canvas() { return this.editor.canvas; }
get zoomFactor() { return this.editor.zoomFactor; }
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.editor.renderItem) return this.editor.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
editPhraseInline(record) {
if (!record || record.kind !== "phrase") return;
const value = record.label || "?????";
record.node.attr("content",
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.editor.labels.relation)}">`);
record.node.redraw();
const element = record.node.element();
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
if (!input) {
record.editWhenRendered = true;
return;
}
const commit = () => {
this.editor.scheduleHistoryCommit();
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.editor.selectItem(record);
};
input.addEventListener("pointerdown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
input.value = value;
input.blur();
}
});
input.addEventListener("blur", commit, { once: true });
input.focus();
input.select();
}
applyItemTypography(record, element) {
const title = element.querySelector(".cmap-card-title");
if (title) {
title.style.color = record.textColor;
title.style.fontFamily = record.fontFamily;
title.style.fontSize = record.fontSize;
title.style.fontWeight = record.fontWeight;
title.style.fontStyle = record.fontStyle;
}
const synopsis = element.querySelector(".cmap-card-synopsis");
if (synopsis) {
synopsis.style.color = record.synopsisTextColor;
synopsis.style.fontFamily = record.synopsisFontFamily;
synopsis.style.fontSize = record.synopsisFontSize;
synopsis.style.fontWeight = record.synopsisFontWeight;
synopsis.style.fontStyle = record.synopsisFontStyle;
}
}
decorateItem(record, renderedElement = null) {
const element = renderedElement || record.node.element();
if (!element) return;
element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase");
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
element.dataset.rwCmapItemId = String(record.id);
element.style.fontFamily = record.fontFamily;
element.style.fontSize = record.fontSize;
element.style.fontWeight = record.fontWeight;
element.style.fontStyle = record.fontStyle;
element.style.overflow = "visible";
this.applyItemTypography(record, element);
if (record.fitContentPending || record.kind !== "phrase") {
this.fitItemToContent(record, element);
}
const image = element.querySelector(".cmap-card-image");
if (image && image.dataset.rwCmapFitBound !== "1") {
image.dataset.rwCmapFitBound = "1";
image.addEventListener("load", () => {
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
record.fitContentPending = true;
this.fitItemToContent(record, element);
}, { once: true });
}
const descriptionButton = element.querySelector(".rw-cmap-view-description");
if (descriptionButton && descriptionButton.dataset.rwCmapBound !== "1") {
descriptionButton.dataset.rwCmapBound = "1";
descriptionButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
descriptionButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.descriptionPageSlug && this.editor.onOpenPage) {
this.editor.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug });
}
});
}
const linkedButton = element.querySelector(".rw-cmap-open-linked");
if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") {
linkedButton.dataset.rwCmapBound = "1";
linkedButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
linkedButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.parentCmapLink) {
if (this.editor.onOpenParentCmap) this.editor.onOpenParentCmap(record);
else this.editor.openParentMap();
} else if (record.cmapSlug && this.editor.onOpenCmap) {
this.editor.onOpenCmap(record);
} else if (record.pageSlug && this.editor.onOpenPage) {
this.editor.onOpenPage(record);
}
});
}
const externalButton = element.querySelector(".rw-cmap-open-external");
if (externalButton && externalButton.dataset.rwCmapBound !== "1") {
externalButton.dataset.rwCmapBound = "1";
externalButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
externalButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.externalUrl && this.editor.onOpenExternalUrl) this.editor.onOpenExternalUrl(record);
});
}
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
id: record.id,
kind: record.kind,
element: elementDescription(element),
style: selectionStyle(element)
});
}
if (this.editor.selectedItems.has(record)) {
element.classList.add("rw-cmap-selected");
element.classList.toggle("rw-cmap-selected-primary", this.editor.selectedItem === record);
element.setAttribute("aria-selected", "true");
if (this.editor.selectedItem === record) this.ensureHandles(record, element);
}
this.ensureSubmapToggle(record, element);
debug("cmap node rendered and decorated", {
id: record.id,
kind: record.kind,
selected: this.editor.selectedItems.has(record),
element: elementDescription(element),
style: selectionStyle(element)
});
if (record.editWhenRendered) {
record.editWhenRendered = false;
queueMicrotask(() => this.editPhraseInline(record));
}
this.editor.ensureCanvasExtent(
Number(record.node.attr("x")) + Number(record.node.attr("width")),
Number(record.node.attr("y")) + Number(record.node.attr("height"))
);
if (!record.moveMembership) {
let parent = record.parentSubmap;
while (parent) {
this.editor.updateSubmapFrame(parent);
parent = parent.parentSubmap;
}
}
}
fitItemToContent(record, element) {
record.fitContentPending = false;
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
const fixedWidth = !record.autoWidth && record.kind !== "phrase";
const probe = document.createElement("div");
probe.className = element.className;
probe.innerHTML = this.itemHtml(record);
Object.assign(probe.style, {
position: "fixed",
left: "-10000px",
top: "0",
width: fixedWidth ? `${record.width}px` : "max-content",
height: "auto",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
boxSizing: "border-box",
fontFamily: record.fontFamily,
fontSize: record.fontSize,
fontWeight: record.fontWeight,
fontStyle: record.fontStyle,
lineHeight: "1.25",
overflow: "visible",
pointerEvents: "none",
transform: "none",
visibility: "hidden",
whiteSpace: "normal"
});
this.applyItemTypography(record, probe);
const content = probe.firstElementChild;
if (content) {
Object.assign(content.style, {
width: fixedWidth ? "100%" : "max-content",
height: "auto",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
overflow: "visible",
whiteSpace: "normal"
});
}
document.body.append(probe);
const bounds = probe.getBoundingClientRect();
probe.remove();
const minimumWidth = record.kind === "phrase" ? 50 : 100;
const minimumHeight = record.kind === "phrase" ? 24 : 40;
const measuredWidth = Math.ceil(bounds.width) + 4;
const measuredHeight = Math.ceil(bounds.height) + 4;
const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width;
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) :
(record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
if (nextWidth === record.width && nextHeight === record.height) return;
const beforeAutomaticLayout = this.editor.onAutomaticLayoutChange ? this.editor.historySnapshot() : null;
const previousWidth = record.width;
const previousHeight = record.height;
const attributes = { width: nextWidth, height: nextHeight };
if (record.kind === "phrase") {
attributes.x = Number(record.node.attr("x")) + ((previousWidth - nextWidth) / 2);
attributes.y = Number(record.node.attr("y")) + ((previousHeight - nextHeight) / 2);
}
record.width = nextWidth;
record.height = nextHeight;
record.node.attr(attributes);
record.node.redraw();
this.editor.redrawConnectorsFor(record);
debug("automatic item size applied", {
id: record.id,
kind: record.kind,
width: nextWidth,
height: nextHeight
});
this.editor.refreshHistorySnapshot();
if (this.editor.onAutomaticLayoutChange) {
const afterAutomaticLayout = this.editor.historySnapshot();
if (beforeAutomaticLayout !== afterAutomaticLayout) {
this.editor.onAutomaticLayoutChange({
beforeSnapshot: beforeAutomaticLayout,
afterSnapshot: afterAutomaticLayout,
itemId: record.id
});
}
}
}
decorateConnector(record, renderedElement = null) {
const element = renderedElement || record.link.element();
if (!element) return;
element.classList.add("rw-cmap-connector");
element.dataset.rwCmapConnectorId = String(record.id);
}
ensureSubmapToggle(record, element) {
let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle");
let open = element.querySelector(":scope > .rw-cmap-submap-open");
if (record.kind !== "submap") {
if (toggle) toggle.remove();
if (open) open.remove();
return;
}
if (record === this.editor.activeMapRoot) {
if (toggle) toggle.remove();
if (open) open.remove();
return;
}
const legacySeparateMap = record.separateMap && !record.cmapSlug;
if (record.expanded && !legacySeparateMap) {
if (toggle) toggle.remove();
toggle = null;
}
if (!toggle) {
if (!record.expanded || legacySeparateMap) {
toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "rw-cmap-submap-toggle";
toggle.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
toggle.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
this.editor.toggleSubmap(record);
});
element.append(toggle);
}
}
if (toggle) {
toggle.textContent = legacySeparateMap ? "↗" : "+";
toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap";
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(record.expanded));
}
if (record.cmapSlug && this.editor.onOpenStoredSubMap) {
if (!open) {
open = document.createElement("button");
open.type = "button";
open.className = "rw-cmap-submap-open";
open.textContent = "↗";
open.title = "Open as separate concept map";
open.setAttribute("aria-label", open.title);
open.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
open.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
this.editor.onOpenStoredSubMap(record);
});
element.append(open);
}
} else if (open) {
open.remove();
}
}
ensureHandles(record, element) {
if (!element.querySelector(":scope > .rw-cmap-relation-handle")) {
const relation = document.createElement("button");
relation.type = "button";
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
relation.title = this.editor.labels.createRelation;
relation.setAttribute("aria-label", this.editor.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.editor.startRelationDrag(event, record));
element.append(relation);
}
if (record.kind !== "phrase" &&
!element.querySelector(":scope > .rw-cmap-edit-handle")) {
const edit = document.createElement("button");
edit.type = "button";
edit.className = "rw-cmap-handle rw-cmap-edit-handle";
edit.title = this.editor.labels.editConcept;
edit.setAttribute("aria-label", this.editor.labels.editConcept);
edit.setAttribute("aria-hidden", "false");
edit.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
edit.addEventListener("mousedown", (event) => event.stopPropagation());
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.editor.selectItem(record);
if (this.editor.onEditItem) this.editor.onEditItem(record);
});
element.append(edit);
}
if (record.kind !== "phrase" &&
!element.querySelector(":scope > .rw-cmap-resize-handle")) {
const resize = document.createElement("button");
resize.type = "button";
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
resize.title = this.editor.labels.resizeConcept;
resize.setAttribute("aria-label", this.editor.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.editor.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
boundaryConceptFor(record, crossedConnector, inside) {
if (!record || record.kind !== "phrase") return record;
for (const connector of this.connectors) {
if (connector === crossedConnector) continue;
if (connector.source !== record && connector.target !== record) continue;
const neighbour = connector.source === record ? connector.target : connector.source;
if (neighbour.kind === "phrase") continue;
if (this.editor.itemInsideActiveMap(neighbour) === inside) return neighbour;
}
return record;
}
refreshBoundaryReferences() {
if (this.editor.boundaryLayer) {
this.editor.boundaryLayer.remove();
this.editor.boundaryLayer = null;
}
if (!this.editor.activeMapRoot) return;
const surface = this.editor.surfaceElement();
if (!surface) return;
const crossings = [];
for (const connector of this.connectors) {
const sourceInside = this.editor.itemInsideActiveMap(connector.source);
const targetInside = this.editor.itemInsideActiveMap(connector.target);
if (sourceInside === targetInside) continue;
const insideRecord = sourceInside ? connector.source : connector.target;
const outsideRecord = sourceInside ? connector.target : connector.source;
const insideConcept = this.boundaryConceptFor(insideRecord, connector, true);
const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false);
if (!insideConcept || !outsideConcept || !this.editor.isItemVisible(insideConcept)) continue;
crossings.push({ connector, sourceInside, insideConcept, outsideConcept });
}
if (!crossings.length) return;
const layer = document.createElement("div");
layer.className = "rw-cmap-boundary-layer";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-boundary-lines");
const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "rw-cmap-boundary-arrow");
marker.setAttribute("viewBox", "0 0 10 10");
marker.setAttribute("refX", "9");
marker.setAttribute("refY", "5");
marker.setAttribute("markerWidth", "7");
marker.setAttribute("markerHeight", "7");
marker.setAttribute("orient", "auto-start-reverse");
const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
arrow.setAttribute("fill", "#4a5560");
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.editor.boundaryLayer = layer;
const viewLeft = this.canvas.scrollLeft / this.zoomFactor;
const viewTop = this.canvas.scrollTop / this.zoomFactor;
const viewWidth = this.canvas.clientWidth / this.zoomFactor;
const viewHeight = this.canvas.clientHeight / this.zoomFactor;
const buttonWidth = 190;
const occupied = { left: [], right: [] };
const reserveY = (side, desired) => {
let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40));
while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34;
if (y > viewTop + viewHeight - 40) y = viewTop + 12;
occupied[side].push(y);
return y;
};
for (const crossing of crossings) {
const insideX = Number(crossing.insideConcept.node.attr("x")) +
(Number(crossing.insideConcept.node.attr("width")) / 2);
const insideY = Number(crossing.insideConcept.node.attr("y")) +
(Number(crossing.insideConcept.node.attr("height")) / 2);
const outsideX = Number(crossing.outsideConcept.node.attr("x")) +
(Number(crossing.outsideConcept.node.attr("width")) / 2);
const side = outsideX < insideX ? "left" : "right";
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
const y = reserveY(side, insideY - 15);
const button = document.createElement("button");
button.type = "button";
button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`;
button.style.transform = `translate(${x}px, ${y}px)`;
button.style.width = `${buttonWidth}px`;
button.textContent = crossing.outsideConcept.label || "External concept";
button.title = "Open the concept map containing this connection";
button.addEventListener("click", () => {
if (this.editor.onOpenBoundaryReference) {
this.editor.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector);
}
});
layer.append(button);
const boundaryX = side === "left" ? x + buttonWidth : x;
const boundaryY = y + 15;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const startX = crossing.sourceInside ? insideX : boundaryX;
const startY = crossing.sourceInside ? insideY : boundaryY;
const endX = crossing.sourceInside ? boundaryX : insideX;
const endY = crossing.sourceInside ? boundaryY : insideY;
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke", crossing.connector.lineColor || "#4a5560");
path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2));
if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
svg.append(path);
}
}
updateSubmapAnchorLine(record, bounds, surface = this.editor.surfaceElement()) {
if (!surface || !bounds || record === this.editor.activeMapRoot || !record.expanded) {
if (record.submapAnchorLineElement) {
record.submapAnchorLineElement.remove();
record.submapAnchorLineElement = null;
}
return;
}
if (!record.submapAnchorLineElement) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-submap-anchor-line");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.append(path);
surface.prepend(svg);
record.submapAnchorLineElement = svg;
}
const anchor = this.editor.itemCenter(record);
const target = {
x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)),
y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom))
};
record.submapAnchorLineElement.querySelector("path")
.setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`);
}
}