big cmap refactoring
This commit is contained in:
+27
-7
@@ -2,12 +2,32 @@
|
|||||||
|
|
||||||
This directory contains the complete browser-side CMap component.
|
This directory contains the complete browser-side CMap component.
|
||||||
|
|
||||||
`cmap.js` is the drawing and hit-testing engine. It is based on the
|
`cmap.js` is the ES-module entry point of the diagram engine. Its public
|
||||||
MIT-licensed ionstage/cmap 0.1.3 source and is now maintained as part of
|
`DiagramEngine`, `DiagramNode` and `DiagramLink` classes expose only the API
|
||||||
racket-wiki. Its public additions include `onSelection` on a map and
|
used by the wiki editor: creating nodes and links, changing their presentation,
|
||||||
`onActivation` and `onRendered` callbacks. Activation is recognized inside the
|
connecting endpoints, zooming and receiving render, selection, activation and
|
||||||
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
|
move events. The engine does not install a `window.Cmap` global.
|
||||||
`dblclick` event that may be suppressed by dragging.
|
|
||||||
|
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
|
`model/concept-repository.js` owns shared concepts, semantic concept relations
|
||||||
and concept ownership. `model/concept-map.js` owns one map's concept
|
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
|
authoritative. None of these model modules contains DOM or drawing-engine
|
||||||
objects, and CMap state is not persisted in browser storage.
|
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
|
`view/appearance-editor.js` presents the appearance model in the concept dialog
|
||||||
and coordinates explicit style and palette changes with its repository.
|
and coordinates explicit style and palette changes with its repository.
|
||||||
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
|
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
|
||||||
|
|||||||
+156
-1842
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
export const debugPrefix = "[racket-wiki:cmap 0.2.122]";
|
||||||
|
|
||||||
|
export function debug(message, details) {
|
||||||
|
if (details === undefined) {
|
||||||
|
console.info(debugPrefix, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.info(debugPrefix, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementDescription(element) {
|
||||||
|
if (!(element instanceof Element)) return String(element);
|
||||||
|
return {
|
||||||
|
tag: element.tagName,
|
||||||
|
id: element.id || null,
|
||||||
|
classes: Array.from(element.classList),
|
||||||
|
itemId: element.dataset.rwCmapItemId || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionStyle(element) {
|
||||||
|
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
pointerEvents: style.pointerEvents,
|
||||||
|
outline: style.outline,
|
||||||
|
outlineOffset: style.outlineOffset,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
overflow: style.overflow,
|
||||||
|
zIndex: style.zIndex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberOr(value, fallback) {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conceptDescriptionReference(label, id) {
|
||||||
|
const slug = String(label || "")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 120)
|
||||||
|
.replace(/-+$/g, "");
|
||||||
|
return `cmap:${slug || `concept-${id}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newConceptId() {
|
||||||
|
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||||
|
try {
|
||||||
|
return window.crypto.randomUUID().toLowerCase();
|
||||||
|
} catch (_error) {
|
||||||
|
// randomUUID can be exposed but forbidden in an insecure/file context.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
||||||
|
window.crypto.getRandomValues(bytes);
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
bytes[index] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||||
|
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConceptTags(tags) {
|
||||||
|
if (!Array.isArray(tags)) return [];
|
||||||
|
return tags.map((tag) => {
|
||||||
|
if (typeof tag === "string") return { type: "label", value: tag.trim() };
|
||||||
|
if (!tag || typeof tag !== "object") return null;
|
||||||
|
return {
|
||||||
|
type: String(tag.type || "label").trim() || "label",
|
||||||
|
value: String(tag.value || tag.name || "").trim()
|
||||||
|
};
|
||||||
|
}).filter((tag) => tag && tag.value);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DiagramEngine } from "./cmap.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Present a CMap through the bundled drawing engine.
|
* Present a CMap through the bundled drawing engine.
|
||||||
*
|
*
|
||||||
@@ -6,11 +8,13 @@
|
|||||||
* rules; CmapEditor remains responsible for interpreting user interaction.
|
* rules; CmapEditor remains responsible for interpreting user interaction.
|
||||||
*/
|
*/
|
||||||
export class CmapView {
|
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 (!(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.canvas = canvas;
|
||||||
this.map = CmapFactory(canvas);
|
this.map = createEngine ? createEngine(canvas) : new DiagramEngine(canvas);
|
||||||
this.map.onSelection(onSelection);
|
this.map.onSelection(onSelection);
|
||||||
this.map.onActivation(onActivation);
|
this.map.onActivation(onActivation);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -362,11 +362,12 @@ body.cmap-mode #main {
|
|||||||
--cmap-a4-width: 1123px;
|
--cmap-a4-width: 1123px;
|
||||||
--cmap-a4-height: 794px;
|
--cmap-a4-height: 794px;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 0 auto;
|
z-index: 1;
|
||||||
|
flex: 1 1 0;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: max(760px, calc(100% - 90px));
|
min-width: max(760px, calc(100% - 90px));
|
||||||
min-height: 794px;
|
min-height: 794px;
|
||||||
overflow: visible;
|
overflow: auto;
|
||||||
border: 0;
|
border: 0;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
background-image:
|
background-image:
|
||||||
@@ -376,6 +377,12 @@ body.cmap-mode #main {
|
|||||||
box-shadow: inset 1px 1px #d9dde2;
|
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 {
|
.cmap-canvas.cmap-page-guides-hidden {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -388,6 +395,15 @@ body.cmap-mode #main {
|
|||||||
transform-origin: 0 0;
|
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 {
|
.rw-cmap-embed {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 1.25rem 0;
|
margin: 1.25rem 0;
|
||||||
@@ -773,7 +789,7 @@ body.cmap-mode #main {
|
|||||||
);
|
);
|
||||||
box-shadow: 0 1px 3px rgb(35 55 30 / 18%);
|
box-shadow: 0 1px 3px rgb(35 55 30 / 18%);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +838,33 @@ body.cmap-mode #main {
|
|||||||
pointer-events: auto;
|
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-layer,
|
||||||
.rw-cmap-boundary-lines {
|
.rw-cmap-boundary-lines {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+7
-2303
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Public handle for one component rendered by a DiagramEngine.
|
||||||
|
*
|
||||||
|
* A handle exposes only the operations used by the CMap editor. The drawing
|
||||||
|
* object remains private to the engine so application code cannot depend on
|
||||||
|
* the representation inherited from the original renderer.
|
||||||
|
*/
|
||||||
|
export class DiagramComponent {
|
||||||
|
constructor(engine, component, attributeNames) {
|
||||||
|
this.engine = engine;
|
||||||
|
this.component = component;
|
||||||
|
this.attributeNames = attributeNames;
|
||||||
|
this.baseVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or update the supported rendering attributes. */
|
||||||
|
attr(name, value) {
|
||||||
|
if (name === undefined) {
|
||||||
|
const attributes = {};
|
||||||
|
for (const attributeName of this.attributeNames) {
|
||||||
|
attributes[attributeName] = this.component[attributeName]();
|
||||||
|
}
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(name)) {
|
||||||
|
for (const [attributeName, attributeValue] of Object.entries(name)) {
|
||||||
|
this.attr(attributeName, attributeValue);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.attributeNames.includes(name)) return this;
|
||||||
|
if (value === undefined) return this.component[name]();
|
||||||
|
|
||||||
|
this.component[name](value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this component from its diagram. */
|
||||||
|
remove() {
|
||||||
|
this.engine.removeComponent(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move this component to the front of its own rendering band. */
|
||||||
|
toFront() {
|
||||||
|
this.engine.drawingSurface.toFront(this.component);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the concrete element currently rendering this component. */
|
||||||
|
element() {
|
||||||
|
return this.component.element();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immediately render the current component state. */
|
||||||
|
redraw() {
|
||||||
|
this.component.redraw();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or change whether the component participates in the presentation. */
|
||||||
|
visible(value) {
|
||||||
|
if (value === undefined) return this.component.visible !== false;
|
||||||
|
|
||||||
|
this.baseVisible = Boolean(value);
|
||||||
|
this.engine.applyFilter(this);
|
||||||
|
return this.component.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback invoked after this component has been rendered. */
|
||||||
|
onRendered(handler) {
|
||||||
|
validateOptionalHandler(handler, "render");
|
||||||
|
this.component.renderedHandler = handler ?
|
||||||
|
(element) => handler(this, element) : null;
|
||||||
|
|
||||||
|
if (handler && this.component.element()) handler(this, this.component.element());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or change whether the component can be dragged. */
|
||||||
|
draggable(enabled) {
|
||||||
|
if (enabled === undefined) {
|
||||||
|
return this.engine.drawingSurface.dragEnabled(this.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) this.engine.drawingSurface.enableDrag(this.component);
|
||||||
|
else this.engine.drawingSurface.disableDrag(this.component);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new object containing only supported input attributes. */
|
||||||
|
export function pickAttributes(attributes, names) {
|
||||||
|
if (attributes === undefined) return {};
|
||||||
|
if (!isPlainObject(attributes)) throw new TypeError("Invalid component attributes");
|
||||||
|
|
||||||
|
const selected = {};
|
||||||
|
for (const name of names) {
|
||||||
|
if (name in attributes) selected[name] = attributes[name];
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate an optional event handler at the public engine boundary. */
|
||||||
|
export function validateOptionalHandler(handler, meaning) {
|
||||||
|
if (handler !== null && handler !== undefined && typeof handler !== "function") {
|
||||||
|
throw new TypeError(`Invalid ${meaning} handler`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine whether a value is a plain attributes object. */
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return typeof value === "object" && value !== null &&
|
||||||
|
Object.prototype.toString.call(value) === "[object Object]";
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { DrawingSurface } from "./drawing-core.js";
|
||||||
|
import { validateOptionalHandler } from "./diagram-component.js";
|
||||||
|
import { DiagramLink } from "./diagram-link.js";
|
||||||
|
import { DiagramNode } from "./diagram-node.js";
|
||||||
|
import { DiagramGroup } from "./diagram-group.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render and interact with a diagram of nodes and links.
|
||||||
|
*
|
||||||
|
* DiagramEngine is the complete public boundary of the drawing engine. It
|
||||||
|
* translates low-level hit-test results to DiagramNode and DiagramLink
|
||||||
|
* handles and owns every rendering object's lifetime.
|
||||||
|
*/
|
||||||
|
export class DiagramEngine {
|
||||||
|
constructor(element) {
|
||||||
|
this.drawingSurface = new DrawingSurface(element);
|
||||||
|
this.handles = new Map();
|
||||||
|
this.selectionHandler = null;
|
||||||
|
this.activationHandler = null;
|
||||||
|
this.filter = null;
|
||||||
|
this.groups = new Set();
|
||||||
|
this.destroyed = false;
|
||||||
|
|
||||||
|
this.drawingSurface.selectionHandler = (component, event) => {
|
||||||
|
if (this.selectionHandler) {
|
||||||
|
this.selectionHandler(component ? this.handleFor(component) : null, event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.drawingSurface.activationHandler = (component, event) => {
|
||||||
|
if (this.activationHandler) {
|
||||||
|
this.activationHandler(component ? this.handleFor(component) : null, event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback for a single hit-tested component selection. */
|
||||||
|
onSelection(handler) {
|
||||||
|
validateOptionalHandler(handler, "selection");
|
||||||
|
this.selectionHandler = handler || null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback for activation of one hit-tested component. */
|
||||||
|
onActivation(handler) {
|
||||||
|
validateOptionalHandler(handler, "activation");
|
||||||
|
this.activationHandler = handler || null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create and render a node owned by this engine. */
|
||||||
|
node(attributes) {
|
||||||
|
this.ensureActive();
|
||||||
|
const node = new DiagramNode(this, attributes);
|
||||||
|
this.addComponent(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create and render a link owned by this engine. */
|
||||||
|
link(attributes) {
|
||||||
|
this.ensureActive();
|
||||||
|
const link = new DiagramLink(this, attributes);
|
||||||
|
this.addComponent(link);
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a generic group frame for nodes and nested groups.
|
||||||
|
* @returns {DiagramGroup} A group owned by this engine.
|
||||||
|
*/
|
||||||
|
group(options) {
|
||||||
|
this.ensureActive();
|
||||||
|
const group = new DiagramGroup(this, options);
|
||||||
|
this.groups.add(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Install a policy that controls component visibility.
|
||||||
|
* pre : filter is null or a function receiving a public component handle.
|
||||||
|
* post : All existing components use the new policy immediately.
|
||||||
|
* result : This engine, for fluent setup.
|
||||||
|
* internals : A policy may return a boolean or `{ visible }`; the component's
|
||||||
|
* own visibility remains the base value and is combined with the policy.
|
||||||
|
*/
|
||||||
|
setFilter(filter) {
|
||||||
|
if (filter !== null && filter !== undefined && typeof filter !== "function") {
|
||||||
|
throw new TypeError("A diagram filter must be a function");
|
||||||
|
}
|
||||||
|
this.filter = filter || null;
|
||||||
|
for (const handle of this.handles.values()) this.applyFilter(handle);
|
||||||
|
for (const group of this.groups) group.redraw();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or update the diagram zoom factor. */
|
||||||
|
zoom(value) {
|
||||||
|
if (value === undefined) return this.drawingSurface.zoomFactor;
|
||||||
|
|
||||||
|
const factor = Number(value);
|
||||||
|
if (!Number.isFinite(factor) || factor <= 0) {
|
||||||
|
throw new TypeError("Invalid zoom factor");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawingSurface.zoomFactor = factor;
|
||||||
|
const element = this.drawingSurface.element();
|
||||||
|
if (element) element.style.zoom = String(factor);
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy the surface and all nodes and links created through this engine. */
|
||||||
|
destroy() {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
|
||||||
|
const element = this.drawingSurface.element();
|
||||||
|
this.selectionHandler = null;
|
||||||
|
this.activationHandler = null;
|
||||||
|
this.drawingSurface.selectionHandler = null;
|
||||||
|
this.drawingSurface.activationHandler = null;
|
||||||
|
for (const group of this.groups) group.destroy();
|
||||||
|
this.groups.clear();
|
||||||
|
for (const component of this.drawingSurface.componentList().toArray()) {
|
||||||
|
component.dispose();
|
||||||
|
component.parentElement(null);
|
||||||
|
}
|
||||||
|
this.drawingSurface.dispose();
|
||||||
|
this.handles.clear();
|
||||||
|
this.destroyed = true;
|
||||||
|
|
||||||
|
if (element && element.parentNode) element.parentNode.removeChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the public handle associated with a low-level drawing object. */
|
||||||
|
handleFor(component) {
|
||||||
|
return this.handles.get(component) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a newly constructed public component to the drawing surface. */
|
||||||
|
addComponent(handle) {
|
||||||
|
this.handles.set(handle.component, handle);
|
||||||
|
this.drawingSurface.add(handle.component);
|
||||||
|
this.applyFilter(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a public component and forget its low-level drawing object. */
|
||||||
|
removeComponent(handle) {
|
||||||
|
if (!handle || handle.engine !== this || !this.handles.has(handle.component)) return;
|
||||||
|
this.drawingSurface.remove(handle.component);
|
||||||
|
this.handles.delete(handle.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the DOM surface on which components and group frames are drawn. */
|
||||||
|
surfaceElement() {
|
||||||
|
return this.drawingSurface.element();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the current base visibility and optional external filter to a handle. */
|
||||||
|
applyFilter(handle) {
|
||||||
|
const decision = this.filter ? this.filter(handle) : true;
|
||||||
|
let visible = typeof decision === "boolean" ? decision : decision?.visible !== false;
|
||||||
|
if (handle instanceof DiagramLink) {
|
||||||
|
const source = handle.sourceNode();
|
||||||
|
const target = handle.targetNode();
|
||||||
|
visible = visible && (!source || source.visible()) && (!target || target.visible());
|
||||||
|
}
|
||||||
|
handle.component.visible = handle.baseVisible && visible;
|
||||||
|
handle.component.redraw();
|
||||||
|
if (handle instanceof DiagramNode) {
|
||||||
|
for (const candidate of this.handles.values()) {
|
||||||
|
if (!(candidate instanceof DiagramLink)) continue;
|
||||||
|
if (candidate.sourceNode() === handle || candidate.targetNode() === handle) {
|
||||||
|
this.applyFilter(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of this.groups) {
|
||||||
|
if (group.members.has(handle)) group.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject operations after the engine and its DOM surface were destroyed. */
|
||||||
|
ensureActive() {
|
||||||
|
if (this.destroyed) throw new Error("The diagram engine has been destroyed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { DiagramComponent } from "./diagram-component.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a nested group frame around diagram components.
|
||||||
|
*
|
||||||
|
* A group is a view-level container, not a domain model. It owns membership,
|
||||||
|
* expanded state and frame geometry while DiagramEngine continues to own the
|
||||||
|
* lifetime of nodes and links. Application code can map a wiki submap or any
|
||||||
|
* other grouping concept onto this generic abstraction.
|
||||||
|
*/
|
||||||
|
export class DiagramGroup {
|
||||||
|
/**
|
||||||
|
* goal : Create an empty diagram group attached to one engine.
|
||||||
|
* pre : engine is a DiagramEngine and options contains only presentation data.
|
||||||
|
* post : The group has no members and has not yet rendered a frame.
|
||||||
|
* result : A DiagramGroup instance.
|
||||||
|
* internals : Membership is kept as a Set so nested groups and repeated add
|
||||||
|
* operations remain deterministic; bounds are calculated from member handles.
|
||||||
|
*/
|
||||||
|
constructor(engine, options = {}) {
|
||||||
|
if (!engine) throw new TypeError("A diagram engine is required");
|
||||||
|
this.engine = engine;
|
||||||
|
this.label = String(options.label || "");
|
||||||
|
this.className = String(options.className || "cmap-group-frame");
|
||||||
|
this.backgroundColor = options.backgroundColor || "transparent";
|
||||||
|
this.borderColor = options.borderColor || "#5d6d7e";
|
||||||
|
this.padding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 24;
|
||||||
|
this.depth = Number.isFinite(Number(options.depth)) ? Number(options.depth) : 0;
|
||||||
|
this.expanded = options.expanded !== false;
|
||||||
|
this.manageVisibility = options.manageVisibility !== false;
|
||||||
|
this.members = new Set();
|
||||||
|
this.elementValue = null;
|
||||||
|
this.onToggleHandler = null;
|
||||||
|
this.onPointerDownHandler = options.onPointerDown || null;
|
||||||
|
this.onDoubleClickHandler = options.onDoubleClick || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Add one node or nested group to this group.
|
||||||
|
* pre : member belongs to the same DiagramEngine.
|
||||||
|
* post : The member contributes to group bounds and rendering.
|
||||||
|
* result : This group, for fluent setup.
|
||||||
|
*/
|
||||||
|
add(member) {
|
||||||
|
if (!(member instanceof DiagramComponent) && !(member instanceof DiagramGroup)) {
|
||||||
|
throw new TypeError("A diagram group member is required");
|
||||||
|
}
|
||||||
|
if (member.engine !== this.engine) throw new TypeError("Group member belongs to another engine");
|
||||||
|
this.members.add(member);
|
||||||
|
this.redraw();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a member and update the frame. */
|
||||||
|
remove(member) {
|
||||||
|
this.members.delete(member);
|
||||||
|
this.redraw();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the callback invoked when the generic frame toggle is clicked. */
|
||||||
|
onToggle(handler) {
|
||||||
|
if (handler !== null && handler !== undefined && typeof handler !== "function") {
|
||||||
|
throw new TypeError("Invalid group toggle handler");
|
||||||
|
}
|
||||||
|
this.onToggleHandler = handler || null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Set the visual appearance of the group frame.
|
||||||
|
* pre : values contains optional CSS colour values.
|
||||||
|
* post : The next redraw uses the supplied background and border colours.
|
||||||
|
* result : This group, for fluent setup.
|
||||||
|
* internals : Values are kept on the group and applied as CSS custom
|
||||||
|
* properties in redraw(), allowing application-specific frame styles.
|
||||||
|
*/
|
||||||
|
setAppearance(values = {}) {
|
||||||
|
if (values.backgroundColor !== undefined) this.backgroundColor = String(values.backgroundColor);
|
||||||
|
if (values.borderColor !== undefined) this.borderColor = String(values.borderColor);
|
||||||
|
if (values.label !== undefined) this.label = String(values.label);
|
||||||
|
this.redraw();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Change whether group contents are shown.
|
||||||
|
* pre : expanded is boolean-coercible.
|
||||||
|
* post : The frame and all member components reflect the new state.
|
||||||
|
* result : The resulting expanded state.
|
||||||
|
* internals : A collapsed group hides direct members; nested groups redraw
|
||||||
|
* themselves so their own frames disappear with the enclosing group.
|
||||||
|
*/
|
||||||
|
setExpanded(expanded) {
|
||||||
|
this.expanded = Boolean(expanded);
|
||||||
|
if (this.manageVisibility) {
|
||||||
|
for (const member of this.members) {
|
||||||
|
if (member instanceof DiagramGroup) member.setExpanded(this.expanded && member.expanded);
|
||||||
|
else member.visible(this.expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.redraw();
|
||||||
|
return this.expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the union bounds of visible member nodes and nested groups. */
|
||||||
|
bounds() {
|
||||||
|
const members = [...this.members]
|
||||||
|
.map((member) => member instanceof DiagramGroup ? member.bounds() :
|
||||||
|
(member.visible() ? this.componentBounds(member) : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!members.length) return null;
|
||||||
|
return {
|
||||||
|
left: Math.min(...members.map((bounds) => bounds.left)) - this.padding,
|
||||||
|
top: Math.min(...members.map((bounds) => bounds.top)) - this.padding,
|
||||||
|
right: Math.max(...members.map((bounds) => bounds.right)) + this.padding,
|
||||||
|
bottom: Math.max(...members.map((bounds) => bounds.bottom)) + this.padding
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the DOM frame, if the group currently has one. */
|
||||||
|
element() {
|
||||||
|
return this.elementValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render or remove the generic frame according to membership and state.
|
||||||
|
* The engine calls this after component redraws; applications may call it
|
||||||
|
* directly after changing group presentation or membership.
|
||||||
|
*/
|
||||||
|
redraw() {
|
||||||
|
const surface = this.engine.surfaceElement();
|
||||||
|
const bounds = this.expanded && this.bounds();
|
||||||
|
if (!surface || !bounds) {
|
||||||
|
this.removeElement();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (!this.elementValue) {
|
||||||
|
const frame = document.createElement("div");
|
||||||
|
frame.className = this.className;
|
||||||
|
frame.setAttribute("role", "group");
|
||||||
|
for (const side of ["top", "right", "bottom", "left"]) {
|
||||||
|
const dragEdge = document.createElement("div");
|
||||||
|
dragEdge.className = `${this.className}-drag-edge ${this.className}-drag-edge-${side}`;
|
||||||
|
if (this.onPointerDownHandler) dragEdge.addEventListener("pointerdown", this.onPointerDownHandler);
|
||||||
|
if (this.onDoubleClickHandler) dragEdge.addEventListener("dblclick", this.onDoubleClickHandler);
|
||||||
|
frame.append(dragEdge);
|
||||||
|
}
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = `${this.className}-toggle`;
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
this.setExpanded(!this.expanded);
|
||||||
|
if (this.onToggleHandler) this.onToggleHandler(this, this.expanded);
|
||||||
|
});
|
||||||
|
frame.append(toggle);
|
||||||
|
surface.prepend(frame);
|
||||||
|
this.elementValue = frame;
|
||||||
|
}
|
||||||
|
const toggle = this.elementValue.querySelector(`.${this.className}-toggle`);
|
||||||
|
if (toggle) {
|
||||||
|
toggle.textContent = this.expanded ? "-" : "+";
|
||||||
|
toggle.setAttribute("aria-expanded", String(this.expanded));
|
||||||
|
toggle.setAttribute("aria-label", this.expanded ? "Collapse group" : "Expand group");
|
||||||
|
}
|
||||||
|
this.elementValue.setAttribute("aria-label", this.label);
|
||||||
|
Object.assign(this.elementValue.style, {
|
||||||
|
left: `${bounds.left}px`,
|
||||||
|
top: `${bounds.top}px`,
|
||||||
|
width: `${bounds.right - bounds.left}px`,
|
||||||
|
height: `${bounds.bottom - bounds.top}px`,
|
||||||
|
zIndex: String(this.depth)
|
||||||
|
});
|
||||||
|
this.elementValue.style.setProperty("--rw-cmap-submap-background", this.backgroundColor);
|
||||||
|
this.elementValue.style.setProperty("--rw-cmap-submap-border", this.borderColor);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the frame and all references owned by this group. */
|
||||||
|
destroy() {
|
||||||
|
for (const member of this.members) {
|
||||||
|
if (member instanceof DiagramGroup) member.destroy();
|
||||||
|
}
|
||||||
|
this.members.clear();
|
||||||
|
this.removeElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentBounds(component) {
|
||||||
|
const attributes = component.attr();
|
||||||
|
const x = Number(attributes.x);
|
||||||
|
const y = Number(attributes.y);
|
||||||
|
const width = Number(attributes.width);
|
||||||
|
const height = Number(attributes.height);
|
||||||
|
if (![x, y, width, height].every(Number.isFinite)) return null;
|
||||||
|
return { left: x, top: y, right: x + width, bottom: y + height };
|
||||||
|
}
|
||||||
|
|
||||||
|
removeElement() {
|
||||||
|
if (this.elementValue) {
|
||||||
|
this.elementValue.remove();
|
||||||
|
this.elementValue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
DiagramComponent,
|
||||||
|
pickAttributes,
|
||||||
|
validateOptionalHandler
|
||||||
|
} from "./diagram-component.js";
|
||||||
|
import { DrawingLink, DrawingSurface, DrawingTriple } from "./drawing-core.js";
|
||||||
|
import { DiagramNode } from "./diagram-node.js";
|
||||||
|
|
||||||
|
const LINK_ATTRIBUTES = [
|
||||||
|
"content",
|
||||||
|
"contentType",
|
||||||
|
"cx",
|
||||||
|
"cy",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"backgroundColor",
|
||||||
|
"borderColor",
|
||||||
|
"borderWidth",
|
||||||
|
"textColor",
|
||||||
|
"sourceX",
|
||||||
|
"sourceY",
|
||||||
|
"targetX",
|
||||||
|
"targetY",
|
||||||
|
"lineColor",
|
||||||
|
"lineWidth",
|
||||||
|
"hasArrow"
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public rendering handle for one diagram link.
|
||||||
|
*
|
||||||
|
* A link owns only visual and interaction state. Its source and target are
|
||||||
|
* DiagramNode instances from the same DiagramEngine.
|
||||||
|
*/
|
||||||
|
export class DiagramLink extends DiagramComponent {
|
||||||
|
constructor(engine, attributes) {
|
||||||
|
const component = new DrawingLink(pickAttributes(attributes, LINK_ATTRIBUTES));
|
||||||
|
super(engine, component, LINK_ATTRIBUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or replace the source node. Pass null to disconnect it. */
|
||||||
|
sourceNode(node) {
|
||||||
|
return this.connectNode(DrawingSurface.CONNECTION_TYPE_SOURCE, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or replace the target node. Pass null to disconnect it. */
|
||||||
|
targetNode(node) {
|
||||||
|
return this.connectNode(DrawingSurface.CONNECTION_TYPE_TARGET, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback for a source or target connection change. */
|
||||||
|
onConnectionChange(handler) {
|
||||||
|
validateOptionalHandler(handler, "connection-change");
|
||||||
|
this.component.connectionChangeHandler = handler ? (type, node, event) => {
|
||||||
|
handler(this, type, node ? this.engine.handleFor(node) : null, event);
|
||||||
|
} : null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Straighten both link segments between their current endpoints. */
|
||||||
|
straighten() {
|
||||||
|
const relation = this.component.relations()
|
||||||
|
.find((candidate) => candidate instanceof DrawingTriple);
|
||||||
|
const sourceNode = relation ? relation.sourceNode() : null;
|
||||||
|
const targetNode = relation ? relation.targetNode() : null;
|
||||||
|
|
||||||
|
if (!sourceNode || !targetNode) {
|
||||||
|
this.component.straighten();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePoint = relation.connectedPoint(
|
||||||
|
sourceNode, targetNode.cx(), targetNode.cy());
|
||||||
|
const targetPoint = relation.connectedPoint(
|
||||||
|
targetNode, sourceNode.cx(), sourceNode.cy());
|
||||||
|
this.component.straighten(
|
||||||
|
sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read or update one connected endpoint. */
|
||||||
|
connectNode(type, node) {
|
||||||
|
const connectedComponent = this.engine.drawingSurface
|
||||||
|
.connectedNode(type, this.component);
|
||||||
|
|
||||||
|
if (node === undefined) {
|
||||||
|
return connectedComponent ? this.engine.handleFor(connectedComponent) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node !== null && !this.validateNode(type, node)) return this;
|
||||||
|
if (connectedComponent) {
|
||||||
|
this.engine.drawingSurface.disconnect(type, connectedComponent, this.component);
|
||||||
|
}
|
||||||
|
if (node !== null) {
|
||||||
|
this.engine.drawingSurface.connect(type, node.component, this.component);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure an endpoint belongs to this engine and is not used at both ends. */
|
||||||
|
validateNode(type, node) {
|
||||||
|
if (!(node instanceof DiagramNode) || node.engine !== this.engine) {
|
||||||
|
throw new TypeError("Invalid diagram node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherType = DrawingSurface.anotherConnectionType(type);
|
||||||
|
const otherNode = this.engine.drawingSurface.connectedNode(otherType, this.component);
|
||||||
|
if (otherNode === node.component) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
DiagramComponent,
|
||||||
|
pickAttributes,
|
||||||
|
validateOptionalHandler
|
||||||
|
} from "./diagram-component.js";
|
||||||
|
import { DrawingNode } from "./drawing-core.js";
|
||||||
|
|
||||||
|
const NODE_ATTRIBUTES = [
|
||||||
|
"content",
|
||||||
|
"contentType",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"backgroundColor",
|
||||||
|
"borderColor",
|
||||||
|
"borderWidth",
|
||||||
|
"textColor"
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public rendering handle for one diagram node.
|
||||||
|
*
|
||||||
|
* DiagramEngine creates nodes and owns their lifetime. The editor uses this
|
||||||
|
* handle to update presentation attributes and receive completed moves.
|
||||||
|
*/
|
||||||
|
export class DiagramNode extends DiagramComponent {
|
||||||
|
constructor(engine, attributes) {
|
||||||
|
const component = new DrawingNode(pickAttributes(attributes, NODE_ATTRIBUTES));
|
||||||
|
super(engine, component, NODE_ATTRIBUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constrain or observe the node while it is being dragged. */
|
||||||
|
onMove(handler) {
|
||||||
|
validateOptionalHandler(handler, "move");
|
||||||
|
this.component.moveHandler = handler ?
|
||||||
|
(x, y) => handler(this, x, y) : null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback for the end of a node drag gesture. */
|
||||||
|
onMoveEnd(handler) {
|
||||||
|
validateOptionalHandler(handler, "move-end");
|
||||||
|
this.component.moveEndHandler = handler ?
|
||||||
|
(x, y, event) => handler(this, x, y, event) : null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Maintain renderer component order and coordinate hit-test priority.
|
||||||
|
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||||
|
*/
|
||||||
|
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
|
||||||
|
import { Component, helper } from "./drawing-support.js";
|
||||||
|
|
||||||
|
class ComponentList extends helper.List {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toFront(component) {
|
||||||
|
var data = this.data;
|
||||||
|
var index = data.indexOf(component);
|
||||||
|
|
||||||
|
if (index === -1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
data.splice(index, 1);
|
||||||
|
data.push(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
fromPoint(ctor, x, y) {
|
||||||
|
var data = this.data;
|
||||||
|
// The visual stack is connector controls, concepts and finally relations.
|
||||||
|
// Use that same priority for coordinate hit testing so a relation that is
|
||||||
|
// hidden behind a concept can never steal the concept's click.
|
||||||
|
var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
|
||||||
|
|
||||||
|
for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
|
||||||
|
var tolerance = toleranceIndex === 0 ? 0 : 8;
|
||||||
|
for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
|
||||||
|
for (var i = data.length - 1; i >= 0; i--) {
|
||||||
|
var component = data[i];
|
||||||
|
|
||||||
|
if (!(component instanceof types[typeIndex]))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (component.visible === false)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (component.contains(x, y, tolerance))
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class DisabledConnectorList extends helper.List {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(type, link) {
|
||||||
|
super.add( {
|
||||||
|
type: type,
|
||||||
|
link: link
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(type, link) {
|
||||||
|
super.remove( {
|
||||||
|
type: type,
|
||||||
|
link: link
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(type, link) {
|
||||||
|
return super.contains( {
|
||||||
|
type: type,
|
||||||
|
link: link
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
equal(a, b) {
|
||||||
|
return a.type === b.type && a.link === b.link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ComponentList, DisabledConnectorList };
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
/**
|
||||||
|
* Render the node, link and connector primitives of a diagram.
|
||||||
|
*
|
||||||
|
* These classes contain geometry and DOM presentation only. Application code
|
||||||
|
* reaches them through DiagramNode and DiagramLink handles.
|
||||||
|
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||||
|
*/
|
||||||
|
import { Component, dom, helper } from "./drawing-support.js";
|
||||||
|
|
||||||
|
class Node extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super();
|
||||||
|
this.visible = true;
|
||||||
|
this.content = this.prop(props.content, '', helper.toString);
|
||||||
|
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
|
||||||
|
this.x = this.prop(props.x, 0, helper.toNumber);
|
||||||
|
this.y = this.prop(props.y, 0, helper.toNumber);
|
||||||
|
this.width = this.prop(props.width, 75, helper.toNumber);
|
||||||
|
this.height = this.prop(props.height, 30, helper.toNumber);
|
||||||
|
this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString);
|
||||||
|
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
|
||||||
|
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
|
||||||
|
this.textColor = this.prop(props.textColor, '#333', helper.toString);
|
||||||
|
this.zIndex = this.prop('auto');
|
||||||
|
this.element = this.prop(null);
|
||||||
|
this.parentElement = this.prop(null);
|
||||||
|
this.cache = this.prop({});
|
||||||
|
this.relations = this.prop([]);
|
||||||
|
this.moveHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cx() {
|
||||||
|
return this.x() + this.width() / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
cy() {
|
||||||
|
return this.y() + this.height() / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
borderRadius() {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(x, y, tolerance) {
|
||||||
|
var nx = this.x();
|
||||||
|
var ny = this.y();
|
||||||
|
var nwidth = this.width();
|
||||||
|
var nheight = this.height();
|
||||||
|
|
||||||
|
return (nx - tolerance <= x && x <= nx + nwidth + tolerance &&
|
||||||
|
ny - tolerance <= y && y <= ny + nheight + tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
style() {
|
||||||
|
var contentType = this.contentType();
|
||||||
|
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
|
||||||
|
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
|
||||||
|
var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)';
|
||||||
|
var borderWidthOffset = this.borderWidth() * 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: this.backgroundColor(),
|
||||||
|
border: this.borderWidth() + 'px solid ' + this.borderColor(),
|
||||||
|
borderRadius: this.borderRadius() + 'px',
|
||||||
|
color: this.textColor(),
|
||||||
|
display: this.visible ? '' : 'none',
|
||||||
|
height: (this.height() - borderWidthOffset) + 'px',
|
||||||
|
lineHeight: (lineHeight - borderWidthOffset) + 'px',
|
||||||
|
msTransform: translate,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
position: 'absolute',
|
||||||
|
textAlign: textAlign,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
transform: translate,
|
||||||
|
webkitTransform: translate,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: (this.width() - borderWidthOffset) + 'px',
|
||||||
|
zIndex: this.zIndex()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw() {
|
||||||
|
var element = this.element();
|
||||||
|
var parentElement = this.parentElement();
|
||||||
|
|
||||||
|
if (!parentElement && !element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// add element
|
||||||
|
if (parentElement && !element) {
|
||||||
|
element = dom.el('<div>');
|
||||||
|
this.element(element);
|
||||||
|
dom.append(parentElement, element);
|
||||||
|
this.redraw();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove element
|
||||||
|
if (!parentElement && element) {
|
||||||
|
dom.remove(element);
|
||||||
|
this.element(null);
|
||||||
|
this.cache({});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache = this.cache();
|
||||||
|
|
||||||
|
// update element
|
||||||
|
var content = this.content();
|
||||||
|
|
||||||
|
if (content !== cache.content) {
|
||||||
|
var contentType = this.contentType();
|
||||||
|
|
||||||
|
if (contentType === helper.CONTENT_TYPE_TEXT)
|
||||||
|
dom.text(element, content);
|
||||||
|
else if (contentType === helper.CONTENT_TYPE_HTML)
|
||||||
|
dom.html(element, content);
|
||||||
|
|
||||||
|
cache.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
var style = this.style();
|
||||||
|
|
||||||
|
dom.css(element, helper.diffObj(style, cache.style));
|
||||||
|
cache.style = style;
|
||||||
|
this.notifyRendered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Link extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super();
|
||||||
|
this.visible = true;
|
||||||
|
this.content = this.prop(props.content, '', helper.toString);
|
||||||
|
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
|
||||||
|
this.cx = this.prop(props.cx, 100, helper.toNumber);
|
||||||
|
this.cy = this.prop(props.cy, 40, helper.toNumber);
|
||||||
|
this.width = this.prop(props.width, 50, helper.toNumber);
|
||||||
|
this.height = this.prop(props.height, 20, helper.toNumber);
|
||||||
|
this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString);
|
||||||
|
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
|
||||||
|
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
|
||||||
|
this.textColor = this.prop(props.textColor, '#333', helper.toString);
|
||||||
|
this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber);
|
||||||
|
this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber);
|
||||||
|
this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber);
|
||||||
|
this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber);
|
||||||
|
this.lineColor = this.prop(props.lineColor, '#333', helper.toString);
|
||||||
|
this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber);
|
||||||
|
this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean);
|
||||||
|
this.zIndex = this.prop('auto');
|
||||||
|
this.element = this.prop(null);
|
||||||
|
this.parentElement = this.prop(null);
|
||||||
|
this.cache = this.prop({});
|
||||||
|
this.relations = this.prop([]);
|
||||||
|
this.connectionChangeHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
straighten(sx, sy, tx, ty) {
|
||||||
|
if (arguments.length === 0) {
|
||||||
|
this.cx((this.sourceX() + this.targetX()) / 2);
|
||||||
|
this.cy((this.sourceY() + this.targetY()) / 2);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cx((sx + tx) / 2);
|
||||||
|
this.cy((sy + ty) / 2);
|
||||||
|
this.sourceX(sx);
|
||||||
|
this.sourceY(sy);
|
||||||
|
this.targetX(tx);
|
||||||
|
this.targetY(ty);
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(x, y, tolerance) {
|
||||||
|
var content = this.content();
|
||||||
|
var lcx = this.cx();
|
||||||
|
var lcy = this.cy();
|
||||||
|
|
||||||
|
// content area
|
||||||
|
if (content) {
|
||||||
|
var lwidth = this.width();
|
||||||
|
var lheight = this.height();
|
||||||
|
|
||||||
|
var lx = lcx - lwidth / 2;
|
||||||
|
var ly = lcy - lheight / 2;
|
||||||
|
|
||||||
|
if (lx - tolerance <= x && x <= lx + lwidth + tolerance &&
|
||||||
|
ly - tolerance <= y && y <= ly + lheight + tolerance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineWidth = this.lineWidth();
|
||||||
|
|
||||||
|
// source path
|
||||||
|
if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// target path
|
||||||
|
if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
containsPath(x0, y0, x1, y1, x, y, d) {
|
||||||
|
var ax = x1 - x0;
|
||||||
|
var ay = y1 - y0;
|
||||||
|
|
||||||
|
var bx = x - x0;
|
||||||
|
var by = y - y0;
|
||||||
|
|
||||||
|
var r = (ax * bx + ay * by) / (ax * ax + ay * ay);
|
||||||
|
|
||||||
|
if (0 <= r && r <= 1) {
|
||||||
|
var px = x0 + r * ax;
|
||||||
|
var py = y0 + r * ay;
|
||||||
|
|
||||||
|
var dx = px - x;
|
||||||
|
var dy = py - y;
|
||||||
|
|
||||||
|
if (dx * dx + dy * dy <= d * d)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
style() {
|
||||||
|
return {
|
||||||
|
display: this.visible ? '' : 'none',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: this.zIndex()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pathContainerStyle() {
|
||||||
|
var width = Math.max(this.cx(), this.sourceX(), this.targetX());
|
||||||
|
var height = Math.max(this.cy(), this.sourceY(), this.targetY());
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: height + 'px',
|
||||||
|
overflow: 'visible',
|
||||||
|
position: 'absolute',
|
||||||
|
width: width + 'px'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
lineAttributes() {
|
||||||
|
var d = [
|
||||||
|
'M', this.sourceX(), this.sourceY(),
|
||||||
|
'L', this.cx(), this.cy(),
|
||||||
|
'L', this.targetX(), this.targetY()
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
d: d,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: this.lineColor(),
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-width': this.lineWidth()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
arrowAttributes() {
|
||||||
|
var cx = this.cx();
|
||||||
|
var cy = this.cy();
|
||||||
|
var tx = this.targetX();
|
||||||
|
var ty = this.targetY();
|
||||||
|
|
||||||
|
var radians = Math.atan2(ty - cy, tx - cx);
|
||||||
|
|
||||||
|
var p0 = {
|
||||||
|
x: 15 * Math.cos(radians - 26 * Math.PI / 180),
|
||||||
|
y: 15 * Math.sin(radians - 26 * Math.PI / 180)
|
||||||
|
};
|
||||||
|
|
||||||
|
var p1 = {
|
||||||
|
x: 15 * Math.cos(radians + 26 * Math.PI / 180),
|
||||||
|
y: 15 * Math.sin(radians + 26 * Math.PI / 180)
|
||||||
|
};
|
||||||
|
|
||||||
|
var p2 = {
|
||||||
|
x: 7 * Math.cos(radians),
|
||||||
|
y: 7 * Math.sin(radians)
|
||||||
|
};
|
||||||
|
|
||||||
|
var d = [
|
||||||
|
'M', tx - p0.x, ty - p0.y,
|
||||||
|
'L', tx, ty,
|
||||||
|
'L', tx - p1.x, ty - p1.y,
|
||||||
|
'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y,
|
||||||
|
'Z'
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
d: d,
|
||||||
|
fill: this.lineColor(),
|
||||||
|
stroke: this.lineColor(),
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
'stroke-width': this.lineWidth(),
|
||||||
|
visibility: this.hasArrow() ? 'visible' : 'hidden'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStyle() {
|
||||||
|
var contentType = this.contentType();
|
||||||
|
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
|
||||||
|
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
|
||||||
|
var x = this.cx() - this.width() / 2;
|
||||||
|
var y = this.cy() - this.height() / 2;
|
||||||
|
var translate = 'translate(' + x + 'px, ' + y + 'px)';
|
||||||
|
var borderWidthOffset = this.borderWidth() * 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: this.backgroundColor(),
|
||||||
|
border: this.borderWidth() + 'px solid ' + this.borderColor(),
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: this.textColor(),
|
||||||
|
height: (this.height() - borderWidthOffset) + 'px',
|
||||||
|
lineHeight: (lineHeight - borderWidthOffset) + 'px',
|
||||||
|
msTransform: translate,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
textAlign: textAlign,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
transform: translate,
|
||||||
|
visibility: this.content() ? 'visible' : 'hidden',
|
||||||
|
webkitTransform: translate,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: (this.width() - borderWidthOffset) + 'px'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw() {
|
||||||
|
var element = this.element();
|
||||||
|
var parentElement = this.parentElement();
|
||||||
|
|
||||||
|
if (!parentElement && !element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// add element
|
||||||
|
if (parentElement && !element) {
|
||||||
|
element = dom.el('<div>');
|
||||||
|
dom.html(element, '<svg><path></path><path></path></svg><div></div>');
|
||||||
|
this.element(element);
|
||||||
|
dom.append(parentElement, element);
|
||||||
|
this.redraw();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove element
|
||||||
|
if (!parentElement && element) {
|
||||||
|
dom.remove(element);
|
||||||
|
this.element(null);
|
||||||
|
this.cache({});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache = this.cache();
|
||||||
|
|
||||||
|
// update path container element
|
||||||
|
var pathContainerStyle = this.pathContainerStyle();
|
||||||
|
var pathContainerElement = dom.child(element, 0);
|
||||||
|
|
||||||
|
dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle));
|
||||||
|
cache.pathContainerElementStyle = contentStyle;
|
||||||
|
|
||||||
|
// update line element
|
||||||
|
var lineAttributes = this.lineAttributes();
|
||||||
|
var lineElement = dom.child(pathContainerElement, 0);
|
||||||
|
|
||||||
|
dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes));
|
||||||
|
cache.lineAttributes = lineAttributes;
|
||||||
|
|
||||||
|
// update arrow element
|
||||||
|
var arrowAttributes = this.arrowAttributes();
|
||||||
|
var arrowElement = dom.child(pathContainerElement, 1);
|
||||||
|
|
||||||
|
dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes));
|
||||||
|
cache.arrowAttributes = arrowAttributes;
|
||||||
|
|
||||||
|
// update content element
|
||||||
|
var content = this.content();
|
||||||
|
var contentStyle = this.contentStyle();
|
||||||
|
var contentElement = dom.child(element, 1);
|
||||||
|
|
||||||
|
if (content !== cache.content) {
|
||||||
|
var contentType = this.contentType();
|
||||||
|
|
||||||
|
if (contentType === helper.CONTENT_TYPE_TEXT)
|
||||||
|
dom.text(contentElement, content);
|
||||||
|
else if (contentType === helper.CONTENT_TYPE_HTML)
|
||||||
|
dom.html(contentElement, content);
|
||||||
|
|
||||||
|
cache.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle));
|
||||||
|
cache.contentStyle = contentStyle;
|
||||||
|
|
||||||
|
// update container element
|
||||||
|
var style = this.style();
|
||||||
|
|
||||||
|
dom.css(element, helper.diffObj(style, cache.style));
|
||||||
|
cache.style = style;
|
||||||
|
this.notifyRendered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Connector extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super();
|
||||||
|
this.x = this.prop(props.x, 0, helper.toNumber);
|
||||||
|
this.y = this.prop(props.y, 0, helper.toNumber);
|
||||||
|
this.color = this.prop(Connector.COLOR_UNCONNECTED);
|
||||||
|
this.zIndex = this.prop('auto');
|
||||||
|
this.element = this.prop(null);
|
||||||
|
this.parentElement = this.prop(null);
|
||||||
|
this.cache = this.prop({});
|
||||||
|
this.relations = this.prop([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
r() {
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(x, y, tolerance) {
|
||||||
|
var dx = x - this.x();
|
||||||
|
var dy = y - this.y();
|
||||||
|
var r = this.r() + tolerance;
|
||||||
|
|
||||||
|
return (dx * dx + dy * dy <= r * r);
|
||||||
|
}
|
||||||
|
|
||||||
|
style() {
|
||||||
|
var r = this.r();
|
||||||
|
var x = this.x() - r;
|
||||||
|
var y = this.y() - r;
|
||||||
|
var translate = 'translate(' + x + 'px, ' + y + 'px)';
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: this.color(),
|
||||||
|
border: '2px solid lightgray',
|
||||||
|
borderRadius: '50%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
height: r * 2 + 'px',
|
||||||
|
msTransform: translate,
|
||||||
|
opacity: 0.6,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
transform: translate,
|
||||||
|
webkitTransform: translate,
|
||||||
|
width: r * 2 + 'px',
|
||||||
|
zIndex: this.zIndex()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw() {
|
||||||
|
var element = this.element();
|
||||||
|
var parentElement = this.parentElement();
|
||||||
|
|
||||||
|
if (!parentElement && !element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// add element
|
||||||
|
if (parentElement && !element) {
|
||||||
|
element = dom.el('<div>');
|
||||||
|
this.element(element);
|
||||||
|
dom.append(parentElement, element);
|
||||||
|
this.redraw();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove element
|
||||||
|
if (!parentElement && element) {
|
||||||
|
dom.remove(element);
|
||||||
|
this.element(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache = this.cache();
|
||||||
|
|
||||||
|
// update element
|
||||||
|
var style = this.style();
|
||||||
|
|
||||||
|
dom.css(element, helper.diffObj(style, cache.style));
|
||||||
|
cache.style = style;
|
||||||
|
this.notifyRendered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Connector.COLOR_CONNECTED = 'lightgreen';
|
||||||
|
Connector.COLOR_UNCONNECTED = 'pink';
|
||||||
|
|
||||||
|
export { Connector, Link as DrawingLink, Node as DrawingNode };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Internal exports of the modular diagram rendering core.
|
||||||
|
*
|
||||||
|
* The implementation is derived from the MIT-licensed ionstage/cmap 0.1.3
|
||||||
|
* renderer and is maintained as part of racket-wiki.
|
||||||
|
*/
|
||||||
|
export { DrawingLink, DrawingNode } from "./drawing-components.js";
|
||||||
|
export { DrawingTriple } from "./drawing-relations.js";
|
||||||
|
export { DrawingSurface } from "./drawing-surface.js";
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Keep diagram endpoints and connector controls geometrically related.
|
||||||
|
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||||
|
*/
|
||||||
|
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
|
||||||
|
|
||||||
|
class Relation {
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
prop(initialValue) {
|
||||||
|
var cache = initialValue;
|
||||||
|
|
||||||
|
return function(value) {
|
||||||
|
if (typeof value === 'undefined')
|
||||||
|
return cache;
|
||||||
|
|
||||||
|
cache = value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Triple extends Relation {
|
||||||
|
constructor(props) {
|
||||||
|
super();
|
||||||
|
this.link = this.prop(props.link);
|
||||||
|
this.sourceNode = this.prop(props.sourceNode || null);
|
||||||
|
this.targetNode = this.prop(props.targetNode || null);
|
||||||
|
this.skipNextUpdate = this.prop(false);
|
||||||
|
this.nodePositionsCache = this.prop({});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(changedComponent) {
|
||||||
|
if (this.skipNextUpdate()) {
|
||||||
|
this.skipNextUpdate(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = this.link();
|
||||||
|
var sourceNode = this.sourceNode();
|
||||||
|
var targetNode = this.targetNode();
|
||||||
|
|
||||||
|
if (changedComponent instanceof Node)
|
||||||
|
this.updateNode(link, sourceNode, targetNode, changedComponent);
|
||||||
|
else if (changedComponent instanceof Link)
|
||||||
|
this.updateLink(link, sourceNode, targetNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNode(link, sourceNode, targetNode, changedNode) {
|
||||||
|
if (sourceNode && targetNode)
|
||||||
|
this.rotateLink(link, sourceNode, targetNode, changedNode);
|
||||||
|
else
|
||||||
|
this.shiftLink(link, sourceNode, targetNode, changedNode);
|
||||||
|
|
||||||
|
this.updateNodePositionsCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateLink(link, sourceNode, targetNode, changedNode) {
|
||||||
|
var cache = this.nodePositionsCache();
|
||||||
|
|
||||||
|
var sncx = cache.sncx;
|
||||||
|
var sncy = cache.sncy;
|
||||||
|
var tncx = cache.tncx;
|
||||||
|
var tncy = cache.tncy;
|
||||||
|
|
||||||
|
var lcx = link.cx();
|
||||||
|
var lcy = link.cy();
|
||||||
|
|
||||||
|
var ts_dx = tncx - sncx;
|
||||||
|
var ts_dy = tncy - sncy;
|
||||||
|
var cs_dx = lcx - sncx;
|
||||||
|
var cs_dy = lcy - sncy;
|
||||||
|
|
||||||
|
var ts_rad0 = Math.atan2(ts_dy, ts_dx);
|
||||||
|
var cs_rad0 = Math.atan2(cs_dy, cs_dx);
|
||||||
|
|
||||||
|
// changed node position
|
||||||
|
if (changedNode === sourceNode) {
|
||||||
|
sncx = sourceNode.cx();
|
||||||
|
sncy = sourceNode.cy();
|
||||||
|
} else if (changedNode === targetNode) {
|
||||||
|
tncx = targetNode.cx();
|
||||||
|
tncy = targetNode.cy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// center positions of two nodes are equal
|
||||||
|
if (cs_rad0 === 0) {
|
||||||
|
link.cx((sncx + tncx) / 2);
|
||||||
|
link.cy((sncy + tncy) / 2);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
|
||||||
|
var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy);
|
||||||
|
|
||||||
|
var ts_cs_rad = ts_rad0 - cs_rad0;
|
||||||
|
|
||||||
|
ts_dx = tncx - sncx;
|
||||||
|
ts_dy = tncy - sncy;
|
||||||
|
|
||||||
|
var ts_rad1 = Math.atan2(ts_dy, ts_dx);
|
||||||
|
var cs_rad1 = ts_rad1 - ts_cs_rad;
|
||||||
|
|
||||||
|
var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
|
||||||
|
var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1;
|
||||||
|
var cs_d1 = cs_d0 * d_rate;
|
||||||
|
|
||||||
|
lcx = sncx + cs_d1 * Math.cos(cs_rad1);
|
||||||
|
lcy = sncy + cs_d1 * Math.sin(cs_rad1);
|
||||||
|
|
||||||
|
link.cx(lcx);
|
||||||
|
link.cy(lcy);
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftLink(link, sourceNode, targetNode, changedNode) {
|
||||||
|
var cache = this.nodePositionsCache();
|
||||||
|
|
||||||
|
var ncx = changedNode.cx();
|
||||||
|
var ncy = changedNode.cy();
|
||||||
|
|
||||||
|
if (changedNode === sourceNode) {
|
||||||
|
link.targetX(link.targetX() + (ncx - cache.sncx));
|
||||||
|
link.targetY(link.targetY() + (ncy - cache.sncy));
|
||||||
|
} else if (changedNode === targetNode) {
|
||||||
|
link.sourceX(link.sourceX() + (ncx - cache.tncx));
|
||||||
|
link.sourceY(link.sourceY() + (ncy - cache.tncy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLink(link, sourceNode, targetNode) {
|
||||||
|
var lx, ly, p;
|
||||||
|
|
||||||
|
if (sourceNode) {
|
||||||
|
// connect link to source node
|
||||||
|
lx = targetNode ? link.cx() : link.targetX();
|
||||||
|
ly = targetNode ? link.cy() : link.targetY();
|
||||||
|
p = this.connectedPoint(sourceNode, lx, ly);
|
||||||
|
link.sourceX(p.x);
|
||||||
|
link.sourceY(p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetNode) {
|
||||||
|
// connect link to target node
|
||||||
|
lx = sourceNode ? link.cx() : link.sourceX();
|
||||||
|
ly = sourceNode ? link.cy() : link.sourceY();
|
||||||
|
p = this.connectedPoint(targetNode, lx, ly);
|
||||||
|
link.targetX(p.x);
|
||||||
|
link.targetY(p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceNode || !targetNode) {
|
||||||
|
// link content moves to midpoint
|
||||||
|
link.cx((link.sourceX() + link.targetX()) / 2);
|
||||||
|
link.cy((link.sourceY() + link.targetY()) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkAngle(radians) {
|
||||||
|
var link = this.link();
|
||||||
|
var sourceNode = this.sourceNode();
|
||||||
|
var targetNode = this.targetNode();
|
||||||
|
|
||||||
|
var ldx = link.targetX() - link.sourceX();
|
||||||
|
var ldy = link.targetY() - link.sourceY();
|
||||||
|
var d = Math.sqrt(ldx * ldx + ldy * ldy);
|
||||||
|
|
||||||
|
var connectedNode = sourceNode || targetNode;
|
||||||
|
var cx = connectedNode.cx();
|
||||||
|
var cy = connectedNode.cy();
|
||||||
|
var lx = cx + d * Math.cos(radians);
|
||||||
|
var ly = cy + d * Math.sin(radians);
|
||||||
|
var p = this.connectedPoint(connectedNode, lx, ly);
|
||||||
|
|
||||||
|
if (connectedNode === sourceNode)
|
||||||
|
link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy);
|
||||||
|
else if (connectedNode === targetNode)
|
||||||
|
link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNodePositionsCache() {
|
||||||
|
var sourceNode = this.sourceNode();
|
||||||
|
var targetNode = this.targetNode();
|
||||||
|
var cache = this.nodePositionsCache();
|
||||||
|
|
||||||
|
if (sourceNode) {
|
||||||
|
cache.sncx = sourceNode.cx();
|
||||||
|
cache.sncy = sourceNode.cy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetNode) {
|
||||||
|
cache.tncx = targetNode.cx();
|
||||||
|
cache.tncy = targetNode.cy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedPoint(node, lx, ly) {
|
||||||
|
var nx = node.x();
|
||||||
|
var ny = node.y();
|
||||||
|
var nwidth = node.width();
|
||||||
|
var nheight = node.height();
|
||||||
|
var ncx = node.cx();
|
||||||
|
var ncy = node.cy();
|
||||||
|
|
||||||
|
var alpha = Math.atan2(ly - ncy, lx - ncx);
|
||||||
|
var beta = Math.PI / 2 - alpha;
|
||||||
|
var t = Math.atan2(nheight, nwidth);
|
||||||
|
|
||||||
|
var x, y;
|
||||||
|
|
||||||
|
// left edge
|
||||||
|
if (alpha < t - Math.PI || alpha > Math.PI - t) {
|
||||||
|
x = nx;
|
||||||
|
y = ncy - nwidth * Math.tan(alpha) / 2;
|
||||||
|
}
|
||||||
|
// top edge
|
||||||
|
else if (alpha < -t) {
|
||||||
|
x = ncx - nheight * Math.tan(beta) / 2;
|
||||||
|
y = ny;
|
||||||
|
}
|
||||||
|
// right edge
|
||||||
|
else if (alpha < t) {
|
||||||
|
x = nx + nwidth;
|
||||||
|
y = ncy + nwidth * Math.tan(alpha) / 2;
|
||||||
|
}
|
||||||
|
// bottom edge
|
||||||
|
else {
|
||||||
|
x = ncx + nheight * Math.tan(beta) / 2;
|
||||||
|
y = ny + nheight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var x0, y0, l, ex, ey;
|
||||||
|
var r = node.borderRadius();
|
||||||
|
var atCorner = false;
|
||||||
|
|
||||||
|
// top-left corner
|
||||||
|
if (x < nx + r && y < ny + r) {
|
||||||
|
x0 = nx + r;
|
||||||
|
y0 = ny + r;
|
||||||
|
atCorner = true;
|
||||||
|
}
|
||||||
|
// top-right corner
|
||||||
|
else if (x > nx + nwidth - r && y < ny + r) {
|
||||||
|
x0 = nx + nwidth - r;
|
||||||
|
y0 = ny + r;
|
||||||
|
atCorner = true;
|
||||||
|
}
|
||||||
|
// bottom-left corner
|
||||||
|
else if (x < nx + r && y > ny + nheight - r) {
|
||||||
|
x0 = nx + r;
|
||||||
|
y0 = ny + nheight - r;
|
||||||
|
atCorner = true;
|
||||||
|
}
|
||||||
|
// bottom-right corner
|
||||||
|
else if (x > nx + nwidth - r && y > ny + nheight - r) {
|
||||||
|
x0 = nx + nwidth - r;
|
||||||
|
y0 = ny + nheight - r;
|
||||||
|
atCorner = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atCorner) {
|
||||||
|
l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y));
|
||||||
|
ex = (x0 - x) / l;
|
||||||
|
ey = (y0 - y) / l;
|
||||||
|
x = x0 - r * ex;
|
||||||
|
y = y0 - r * ey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x,
|
||||||
|
y: y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class LinkConnectorRelation extends Relation {
|
||||||
|
constructor(props) {
|
||||||
|
super();
|
||||||
|
this.type = this.prop(props.type);
|
||||||
|
this.link = this.prop(props.link);
|
||||||
|
this.connector = this.prop(props.connector);
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(isConnected) {
|
||||||
|
var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED;
|
||||||
|
this.connector().color(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(changedComponent) {
|
||||||
|
var type = this.type();
|
||||||
|
var link = this.link();
|
||||||
|
var connector = this.connector();
|
||||||
|
|
||||||
|
if (changedComponent === link) {
|
||||||
|
connector.x(link[type + 'X']());
|
||||||
|
connector.y(link[type + 'Y']());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LinkConnectorRelation, Triple as DrawingTriple };
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Shared DOM and state support for the low-level diagram renderer.
|
||||||
|
*
|
||||||
|
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||||
|
*/
|
||||||
|
const CONTENT_TYPE_TEXT = 'text';
|
||||||
|
const CONTENT_TYPE_HTML = 'html';
|
||||||
|
|
||||||
|
class ItemList {
|
||||||
|
constructor() {
|
||||||
|
this.data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
add(item) {
|
||||||
|
if (!this.contains(item)) this.data.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(item) {
|
||||||
|
for (let index = this.data.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (this.equal(this.data[index], item)) {
|
||||||
|
this.data.splice(index, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(item) {
|
||||||
|
return this.data.some((candidate) => this.equal(candidate, item));
|
||||||
|
}
|
||||||
|
|
||||||
|
equal(first, second) {
|
||||||
|
return first === second;
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() {
|
||||||
|
return this.data.slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const helper = {
|
||||||
|
CONTENT_TYPE_HTML,
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
List: ItemList,
|
||||||
|
|
||||||
|
toNumber(value, defaultValue) {
|
||||||
|
return !isNaN(value) ? Number(value) : defaultValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
toString(value, defaultValue) {
|
||||||
|
return value !== undefined ? String(value) : defaultValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
toBoolean(value, defaultValue) {
|
||||||
|
return value !== undefined ? Boolean(value) : defaultValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
toContentType(value, defaultValue) {
|
||||||
|
if (value === CONTENT_TYPE_TEXT || value === CONTENT_TYPE_HTML) return value;
|
||||||
|
return defaultValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
eachInstance(values, constructor, callback) {
|
||||||
|
values.filter((value) => value instanceof constructor).forEach(callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
firstInstance(values, constructor) {
|
||||||
|
return values.find((value) => value instanceof constructor);
|
||||||
|
},
|
||||||
|
|
||||||
|
diffObj(newObject, oldObject) {
|
||||||
|
const difference = {};
|
||||||
|
for (const key in newObject) {
|
||||||
|
if (!oldObject || newObject[key] !== oldObject[key]) {
|
||||||
|
difference[key] = newObject[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return difference;
|
||||||
|
},
|
||||||
|
|
||||||
|
identity(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var dom = {};
|
||||||
|
|
||||||
|
dom.disabled = function() {
|
||||||
|
return (typeof document === 'undefined');
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.el = function(selector) {
|
||||||
|
if (selector.charAt(0) === '<') {
|
||||||
|
selector = selector.match(/<(.+)>/)[1];
|
||||||
|
return document.createElement(selector);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.body = function() {
|
||||||
|
return document.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.attr = function(el, props) {
|
||||||
|
for (var key in props) {
|
||||||
|
el.setAttribute(key, props[key]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.css = function(el, props) {
|
||||||
|
var style = el.style;
|
||||||
|
|
||||||
|
for (var key in props) {
|
||||||
|
style[key] = props[key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.rect = function(el) {
|
||||||
|
return el.getBoundingClientRect();
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.clientWidth = function(el) {
|
||||||
|
return el.clientWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.clientHeight = function(el) {
|
||||||
|
return el.clientHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.scrollLeft = function(el) {
|
||||||
|
return el.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.scrollTop = function(el) {
|
||||||
|
return el.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.scrollWidth = function(el) {
|
||||||
|
return el.scrollWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.scrollHeight = function(el) {
|
||||||
|
return el.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.text = function(el, s) {
|
||||||
|
el.textContent = s;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.html = function(el, s) {
|
||||||
|
el.innerHTML = s;
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.append = function(parent, el) {
|
||||||
|
parent.appendChild(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.remove = function(el) {
|
||||||
|
el.parentNode.removeChild(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.child = function(el, index) {
|
||||||
|
return el.childNodes[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.animate = function(callback) {
|
||||||
|
return window.requestAnimationFrame(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.supportsTouch = function() {
|
||||||
|
return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch));
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.on = function(el, type, listener) {
|
||||||
|
el.addEventListener(type, listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.off = function(el, type, listener) {
|
||||||
|
el.removeEventListener(type, listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.pagePoint = function(event, offset) {
|
||||||
|
if (dom.supportsTouch())
|
||||||
|
event = event.changedTouches[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: event.pageX - (offset ? offset.x : 0),
|
||||||
|
y: event.pageY - (offset ? offset.y : 0)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.clientPoint = function(event, offset) {
|
||||||
|
if (dom.supportsTouch())
|
||||||
|
event = event.changedTouches[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: event.clientX - (offset ? offset.x : 0),
|
||||||
|
y: event.clientY - (offset ? offset.y : 0)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.cancel = function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
class Draggable {
|
||||||
|
constructor(element, onStart, onMove, onEnd) {
|
||||||
|
this.element = element;
|
||||||
|
this.onStart = onStart;
|
||||||
|
this.onMove = onMove;
|
||||||
|
this.onEnd = onEnd;
|
||||||
|
this.start = this.start.bind(this);
|
||||||
|
this.move = this.move.bind(this);
|
||||||
|
this.end = this.end.bind(this);
|
||||||
|
this.locked = false;
|
||||||
|
this.startingPoint = null;
|
||||||
|
this.startEvent = dom.supportsTouch() ? 'touchstart' : 'mousedown';
|
||||||
|
this.moveEvent = dom.supportsTouch() ? 'touchmove' : 'mousemove';
|
||||||
|
this.endEvent = dom.supportsTouch() ? 'touchend' : 'mouseup';
|
||||||
|
|
||||||
|
dom.on(this.element, this.startEvent, this.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(event) {
|
||||||
|
if (this.locked)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.locked = true;
|
||||||
|
this.startingPoint = dom.pagePoint(event);
|
||||||
|
const rectangle = dom.rect(this.element);
|
||||||
|
const point = dom.clientPoint(event, {
|
||||||
|
x: rectangle.left - dom.scrollLeft(this.element),
|
||||||
|
y: rectangle.top - dom.scrollTop(this.element)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof this.onStart === 'function') this.onStart(point.x, point.y, event);
|
||||||
|
|
||||||
|
dom.on(document, this.moveEvent, this.move);
|
||||||
|
dom.on(document, this.endEvent, this.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
move(event) {
|
||||||
|
const distance = dom.pagePoint(event, this.startingPoint);
|
||||||
|
if (typeof this.onMove === 'function') this.onMove(distance.x, distance.y, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
end(event) {
|
||||||
|
dom.off(document, this.moveEvent, this.move);
|
||||||
|
dom.off(document, this.endEvent, this.end);
|
||||||
|
|
||||||
|
const distance = dom.pagePoint(event, this.startingPoint);
|
||||||
|
if (typeof this.onEnd === 'function') this.onEnd(distance.x, distance.y, event);
|
||||||
|
|
||||||
|
this.locked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.draggable = function(element, onStart, onMove, onEnd) {
|
||||||
|
if (dom.disabled()) return null;
|
||||||
|
return new Draggable(element, onStart, onMove, onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirtyComponents = [];
|
||||||
|
let renderRequestId = null;
|
||||||
|
|
||||||
|
class Component {
|
||||||
|
constructor() {
|
||||||
|
this.disposed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
prop(initialValue, defaultValue, converter) {
|
||||||
|
const convert = typeof converter === 'function' ? converter : helper.identity;
|
||||||
|
let cache = convert(initialValue, defaultValue);
|
||||||
|
|
||||||
|
return (value) => {
|
||||||
|
if (typeof value === 'undefined')
|
||||||
|
return cache;
|
||||||
|
|
||||||
|
if (value === cache)
|
||||||
|
return;
|
||||||
|
|
||||||
|
cache = convert(value, cache);
|
||||||
|
this.markDirty();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
relations() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw() {}
|
||||||
|
|
||||||
|
notifyRendered() {
|
||||||
|
if (typeof this.renderedHandler === 'function' && this.element())
|
||||||
|
this.renderedHandler(this.element());
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty() {
|
||||||
|
if (dom.disabled() || this.disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!dirtyComponents.includes(this))
|
||||||
|
dirtyComponents.push(this);
|
||||||
|
|
||||||
|
if (renderRequestId !== null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
renderRequestId = dom.animate(redrawDirtyComponents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDirtyRelations(index) {
|
||||||
|
const initialLength = dirtyComponents.length;
|
||||||
|
for (let position = index; position < initialLength; position += 1) {
|
||||||
|
const component = dirtyComponents[position];
|
||||||
|
if (component.disposed)
|
||||||
|
continue;
|
||||||
|
component.relations().forEach((relation) => {
|
||||||
|
if (!relation.disposed)
|
||||||
|
relation.update(component);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirtyComponents.length > initialLength)
|
||||||
|
updateDirtyRelations(initialLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawDirtyComponents() {
|
||||||
|
updateDirtyRelations(0);
|
||||||
|
dirtyComponents.forEach((component) => {
|
||||||
|
if (!component.disposed)
|
||||||
|
component.redraw();
|
||||||
|
});
|
||||||
|
dirtyComponents.length = 0;
|
||||||
|
renderRequestId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Component, dom, helper };
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
/**
|
||||||
|
* Own the diagram surface, component relations and pointer interaction.
|
||||||
|
*
|
||||||
|
* DrawingSurface is internal to DiagramEngine. It coordinates the primitive
|
||||||
|
* renderers, performs hit testing and translates drag gestures to geometry.
|
||||||
|
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||||
|
*/
|
||||||
|
import { ComponentList, DisabledConnectorList } from "./drawing-collections.js";
|
||||||
|
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
|
||||||
|
import { DrawingTriple as Triple, LinkConnectorRelation } from "./drawing-relations.js";
|
||||||
|
import { Component, dom, helper } from "./drawing-support.js";
|
||||||
|
|
||||||
|
class Cmap extends Component {
|
||||||
|
constructor(rootElement) {
|
||||||
|
super();
|
||||||
|
this.componentList = this.prop(new ComponentList());
|
||||||
|
this.disabledConnectorList = this.prop(new DisabledConnectorList());
|
||||||
|
this.dragDisabledComponentList = this.prop(new ComponentList());
|
||||||
|
this.element = this.prop(null);
|
||||||
|
this.rootElement = this.prop(rootElement || null);
|
||||||
|
this.retainerElement = this.prop(null);
|
||||||
|
this.dragContext = this.prop({});
|
||||||
|
this.selectionHandler = null;
|
||||||
|
this.activationHandler = null;
|
||||||
|
this.lastClickComponent = null;
|
||||||
|
this.lastClickTime = 0;
|
||||||
|
this.zoomFactor = 1;
|
||||||
|
|
||||||
|
this.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(component) {
|
||||||
|
component.parentElement(this.element());
|
||||||
|
this.componentList().add(component);
|
||||||
|
this.updateZIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
static anotherConnectionType(type) {
|
||||||
|
if (type === Cmap.CONNECTION_TYPE_SOURCE)
|
||||||
|
return Cmap.CONNECTION_TYPE_TARGET;
|
||||||
|
else if (type === Cmap.CONNECTION_TYPE_TARGET)
|
||||||
|
return Cmap.CONNECTION_TYPE_SOURCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(component) {
|
||||||
|
component.parentElement(null);
|
||||||
|
|
||||||
|
if (component instanceof Link)
|
||||||
|
this.hideConnectors(component);
|
||||||
|
|
||||||
|
this.disconnect(component);
|
||||||
|
this.componentList().remove(component);
|
||||||
|
this.updateZIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
toFront(component) {
|
||||||
|
this.componentList().toFront(component);
|
||||||
|
this.updateZIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateZIndex() {
|
||||||
|
var linkIndex = 0;
|
||||||
|
var nodeIndex = 0;
|
||||||
|
this.componentList().toArray().forEach(function(component) {
|
||||||
|
if (component instanceof Connector)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Relations always occupy a lower band than concept nodes. Reordering a
|
||||||
|
// selected component therefore only changes its order inside that band.
|
||||||
|
var zIndex = component instanceof Link ?
|
||||||
|
Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
|
||||||
|
Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
|
||||||
|
component.zIndex(zIndex);
|
||||||
|
|
||||||
|
if (!(component instanceof Link))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// update connector z-index of link
|
||||||
|
helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
|
||||||
|
relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(type, node, link) {
|
||||||
|
var linkRelations = link.relations();
|
||||||
|
var triple = helper.firstInstance(linkRelations, Triple);
|
||||||
|
var nodeKey = type + 'Node';
|
||||||
|
|
||||||
|
if (triple && triple[nodeKey]())
|
||||||
|
throw new Error('Already connected');
|
||||||
|
|
||||||
|
var anotherType = Cmap.anotherConnectionType(type);
|
||||||
|
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
|
||||||
|
|
||||||
|
if (anotherSideNode === node)
|
||||||
|
throw new Error('Already connected to the ' + anotherType + ' of the link');
|
||||||
|
|
||||||
|
if (triple) {
|
||||||
|
triple[nodeKey](node);
|
||||||
|
} else {
|
||||||
|
var tripleProps = {};
|
||||||
|
tripleProps.link = link;
|
||||||
|
tripleProps[nodeKey] = node;
|
||||||
|
triple = new Triple(tripleProps);
|
||||||
|
|
||||||
|
// add triple to the beginning of link relations to be ahead of link-connector relation
|
||||||
|
// connector position won't be updated before triple update
|
||||||
|
linkRelations.unshift(triple);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add triple to node
|
||||||
|
node.relations().push(triple);
|
||||||
|
triple.updateNodePositionsCache();
|
||||||
|
|
||||||
|
// update connectors of link
|
||||||
|
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
|
||||||
|
if (relation.type() === type)
|
||||||
|
relation.isConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// link content moves to midpoint of connected nodes
|
||||||
|
if (anotherSideNode) {
|
||||||
|
link.cx((node.cx() + anotherSideNode.cx()) / 2);
|
||||||
|
link.cy((node.cy() + anotherSideNode.cy()) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// do not need to mark node dirty (stay unchanged)
|
||||||
|
link.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(type, node, link) {
|
||||||
|
if (type instanceof Component) {
|
||||||
|
var component = type;
|
||||||
|
var relations = component.relations().slice();
|
||||||
|
|
||||||
|
// disconnect all connections of component
|
||||||
|
helper.eachInstance(relations, Triple, function(triple) {
|
||||||
|
var link = triple.link();
|
||||||
|
var sourceNode = triple.sourceNode();
|
||||||
|
var targetNode = triple.targetNode();
|
||||||
|
|
||||||
|
if (sourceNode && (component === link || component === sourceNode))
|
||||||
|
this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link);
|
||||||
|
|
||||||
|
if (targetNode && (component === link || component === targetNode))
|
||||||
|
this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link);
|
||||||
|
}.bind(this));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkRelations = link.relations();
|
||||||
|
var triple = helper.firstInstance(linkRelations, Triple);
|
||||||
|
var nodeKey = type + 'Node';
|
||||||
|
|
||||||
|
if (!triple || triple[nodeKey]() !== node)
|
||||||
|
throw new Error('Not connected');
|
||||||
|
|
||||||
|
triple[nodeKey](null);
|
||||||
|
|
||||||
|
// remove triple from node
|
||||||
|
var nodeRelations = node.relations();
|
||||||
|
nodeRelations.splice(nodeRelations.indexOf(triple), 1);
|
||||||
|
|
||||||
|
// remove triple from link
|
||||||
|
if (!triple.sourceNode() && !triple.targetNode())
|
||||||
|
linkRelations.splice(linkRelations.indexOf(triple), 1);
|
||||||
|
|
||||||
|
// update connectors of link
|
||||||
|
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
|
||||||
|
if (relation.type() === type)
|
||||||
|
relation.isConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// do not need to mark node dirty (stay unchanged)
|
||||||
|
link.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedNode(type, link) {
|
||||||
|
var triple = helper.firstInstance(link.relations(), Triple);
|
||||||
|
|
||||||
|
if (!triple)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return triple[type + 'Node']();
|
||||||
|
}
|
||||||
|
|
||||||
|
showConnector(type, link) {
|
||||||
|
if (this.connectorVisible(type, link))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var disabledConnectorList = this.disabledConnectorList();
|
||||||
|
var connectorDisabled = disabledConnectorList.contains(type, link);
|
||||||
|
|
||||||
|
if (!connectorDisabled)
|
||||||
|
this.addConnector(type, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectorVisible(type, link) {
|
||||||
|
return link.relations().some(function(relation) {
|
||||||
|
return relation instanceof LinkConnectorRelation && relation.type() === type;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addConnector(type, link) {
|
||||||
|
var connector = new Connector({
|
||||||
|
x: link[type + 'X'](),
|
||||||
|
y: link[type + 'Y']()
|
||||||
|
});
|
||||||
|
|
||||||
|
var linkConnectorRelation = new LinkConnectorRelation({
|
||||||
|
type: type,
|
||||||
|
link: link,
|
||||||
|
connector: connector
|
||||||
|
});
|
||||||
|
|
||||||
|
var linkRelations = link.relations();
|
||||||
|
var triple = helper.firstInstance(linkRelations, Triple);
|
||||||
|
var isConnected = (triple && !!triple[type + 'Node']());
|
||||||
|
|
||||||
|
linkConnectorRelation.isConnected(isConnected);
|
||||||
|
linkRelations.push(linkConnectorRelation);
|
||||||
|
connector.relations().push(linkConnectorRelation);
|
||||||
|
|
||||||
|
this.add(connector);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideConnector(type, link) {
|
||||||
|
var linkRelations = link.relations();
|
||||||
|
|
||||||
|
for (var i = linkRelations.length - 1; i >= 0; i--) {
|
||||||
|
var relation = linkRelations[i];
|
||||||
|
|
||||||
|
if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// remove connector component
|
||||||
|
this.remove(relation.connector());
|
||||||
|
|
||||||
|
// remove link-connector relation from link
|
||||||
|
linkRelations.splice(i, 1);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showConnectors(link) {
|
||||||
|
this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
|
||||||
|
this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideConnectors(link) {
|
||||||
|
this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
|
||||||
|
this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideAllConnectors() {
|
||||||
|
this.componentList().toArray().forEach(function(component) {
|
||||||
|
if (component instanceof Link)
|
||||||
|
this.hideConnectors(component);
|
||||||
|
}.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
enableConnector(type, link) {
|
||||||
|
this.disabledConnectorList().remove(type, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableConnector(type, link) {
|
||||||
|
// remove showing connector
|
||||||
|
this.hideConnector(type, link);
|
||||||
|
|
||||||
|
this.disabledConnectorList().add(type, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectorEnabled(type, link) {
|
||||||
|
return !this.disabledConnectorList().contains(type, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableDrag(component) {
|
||||||
|
this.dragDisabledComponentList().remove(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableDrag(component) {
|
||||||
|
this.dragDisabledComponentList().add(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragEnabled(component) {
|
||||||
|
return !this.dragDisabledComponentList().contains(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
onstart(x, y, event) {
|
||||||
|
var context = this.dragContext();
|
||||||
|
|
||||||
|
var component = this.componentList().fromPoint(Component, x, y);
|
||||||
|
context.component = component;
|
||||||
|
|
||||||
|
if (typeof this.selectionHandler === 'function')
|
||||||
|
this.selectionHandler(component, event);
|
||||||
|
|
||||||
|
if (!(component instanceof Connector))
|
||||||
|
this.hideAllConnectors();
|
||||||
|
|
||||||
|
if (!component)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var draggable = !this.dragDisabledComponentList().contains(component);
|
||||||
|
context.draggable = draggable;
|
||||||
|
|
||||||
|
if (!draggable)
|
||||||
|
return;
|
||||||
|
|
||||||
|
dom.cancel(event);
|
||||||
|
|
||||||
|
this.toFront(component);
|
||||||
|
|
||||||
|
if (component instanceof Node) {
|
||||||
|
context.x = component.x();
|
||||||
|
context.y = component.y();
|
||||||
|
} else if (component instanceof Link) {
|
||||||
|
context.cx = component.cx();
|
||||||
|
context.cy = component.cy();
|
||||||
|
context.sourceX = component.sourceX();
|
||||||
|
context.sourceY = component.sourceY();
|
||||||
|
context.targetX = component.targetX();
|
||||||
|
context.targetY = component.targetY();
|
||||||
|
context.triple = helper.firstInstance(component.relations(), Triple);
|
||||||
|
|
||||||
|
this.showConnectors(component);
|
||||||
|
} else if (component instanceof Connector) {
|
||||||
|
var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation);
|
||||||
|
|
||||||
|
context.x = x;
|
||||||
|
context.y = y;
|
||||||
|
context.type = linkConnectorRelation.type();
|
||||||
|
context.link = linkConnectorRelation.link();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fixScrollSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
onmove(dx, dy, event) {
|
||||||
|
var context = this.dragContext();
|
||||||
|
|
||||||
|
var component = context.component;
|
||||||
|
|
||||||
|
if (!component)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!context.draggable)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (component instanceof Node) {
|
||||||
|
var nodeX = context.x + dx;
|
||||||
|
var nodeY = context.y + dy;
|
||||||
|
|
||||||
|
if (typeof component.moveHandler === 'function') {
|
||||||
|
var constrainedPosition = component.moveHandler(nodeX, nodeY);
|
||||||
|
|
||||||
|
if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) {
|
||||||
|
nodeX = constrainedPosition.x;
|
||||||
|
nodeY = constrainedPosition.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component.x(nodeX);
|
||||||
|
component.y(nodeY);
|
||||||
|
} else if (component instanceof Link) {
|
||||||
|
var cx = context.cx + dx;
|
||||||
|
var cy = context.cy + dy;
|
||||||
|
var triple = context.triple;
|
||||||
|
var connectedNode = null;
|
||||||
|
|
||||||
|
if (triple) {
|
||||||
|
var sourceNode = triple.sourceNode();
|
||||||
|
var targetNode = triple.targetNode();
|
||||||
|
|
||||||
|
if (sourceNode && !targetNode)
|
||||||
|
connectedNode = sourceNode;
|
||||||
|
else if (!sourceNode && targetNode)
|
||||||
|
connectedNode = targetNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectedNode) {
|
||||||
|
// only one node connected
|
||||||
|
var x = cx - connectedNode.cx();
|
||||||
|
var y = cy - connectedNode.cy();
|
||||||
|
triple.updateLinkAngle(Math.atan2(y, x));
|
||||||
|
triple.skipNextUpdate(true);
|
||||||
|
} else if (!triple || component.content()) {
|
||||||
|
// not connected or link has content
|
||||||
|
// (except two nodes connected but link has no content)
|
||||||
|
component.cx(cx);
|
||||||
|
component.cy(cy);
|
||||||
|
component.sourceX(context.sourceX + dx);
|
||||||
|
component.sourceY(context.sourceY + dy);
|
||||||
|
component.targetX(context.targetX + dx);
|
||||||
|
component.targetY(context.targetY + dy);
|
||||||
|
}
|
||||||
|
} else if (component instanceof Connector) {
|
||||||
|
var x = context.x + dx;
|
||||||
|
var y = context.y + dy;
|
||||||
|
var type = context.type;
|
||||||
|
var link = context.link;
|
||||||
|
|
||||||
|
var triple = helper.firstInstance(link.relations(), Triple);
|
||||||
|
var connectedNode = triple ? triple[type + 'Node']() : null;
|
||||||
|
var node = this.componentList().fromPoint(Node, x, y);
|
||||||
|
|
||||||
|
if (connectedNode && connectedNode === node) {
|
||||||
|
// already connected (do nothing)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var anotherType = Cmap.anotherConnectionType(type);
|
||||||
|
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
|
||||||
|
|
||||||
|
if (connectedNode && connectedNode !== node) {
|
||||||
|
this.disconnect(type, connectedNode, link);
|
||||||
|
connectedNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsConnect = !connectedNode && node && anotherSideNode !== node;
|
||||||
|
|
||||||
|
if (needsConnect) {
|
||||||
|
if (anotherSideNode) {
|
||||||
|
var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy());
|
||||||
|
|
||||||
|
link[type + 'X'](p.x);
|
||||||
|
link[type + 'Y'](p.y);
|
||||||
|
|
||||||
|
triple.update(link);
|
||||||
|
triple.skipNextUpdate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connect(type, node, link);
|
||||||
|
} else {
|
||||||
|
link[type + 'X'](x);
|
||||||
|
link[type + 'Y'](y);
|
||||||
|
|
||||||
|
if (!anotherSideNode)
|
||||||
|
link.straighten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onend(dx, dy, event) {
|
||||||
|
var context = this.dragContext();
|
||||||
|
|
||||||
|
var component = context.component;
|
||||||
|
|
||||||
|
if (!component) {
|
||||||
|
this.lastClickComponent = null;
|
||||||
|
this.lastClickTime = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.draggable) {
|
||||||
|
this.lastClickComponent = null;
|
||||||
|
this.lastClickTime = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dx/dy are logical map coordinates; keep the click tolerance at four
|
||||||
|
// physical screen pixels at every zoom level.
|
||||||
|
var clickTolerance = 4 / this.zoomFactor;
|
||||||
|
var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance;
|
||||||
|
|
||||||
|
if (isClick) {
|
||||||
|
var now = Date.now();
|
||||||
|
var isDoubleClick = (component === this.lastClickComponent &&
|
||||||
|
now - this.lastClickTime <= 500);
|
||||||
|
|
||||||
|
if (isDoubleClick) {
|
||||||
|
this.lastClickComponent = null;
|
||||||
|
this.lastClickTime = 0;
|
||||||
|
|
||||||
|
if (typeof this.activationHandler === 'function')
|
||||||
|
this.activationHandler(component, event);
|
||||||
|
} else {
|
||||||
|
this.lastClickComponent = component;
|
||||||
|
this.lastClickTime = now;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.lastClickComponent = null;
|
||||||
|
this.lastClickTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component instanceof Node && typeof component.moveEndHandler === 'function')
|
||||||
|
component.moveEndHandler(component.x(), component.y(), event);
|
||||||
|
|
||||||
|
if (component instanceof Connector) {
|
||||||
|
var link = context.link;
|
||||||
|
var triple = helper.firstInstance(link.relations(), Triple);
|
||||||
|
var connectedNode = triple ? triple[context.type + 'Node']() : null;
|
||||||
|
|
||||||
|
if (typeof link.connectionChangeHandler === 'function')
|
||||||
|
link.connectionChangeHandler(context.type, connectedNode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unfixScrollSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
fixScrollSize() {
|
||||||
|
var element = this.element();
|
||||||
|
|
||||||
|
var clientWidth = dom.clientWidth(element);
|
||||||
|
var clientHeight = dom.clientHeight(element);
|
||||||
|
var scrollWidth = dom.scrollWidth(element);
|
||||||
|
var scrollHeight = dom.scrollHeight(element);
|
||||||
|
|
||||||
|
// check if scrolled
|
||||||
|
if (clientWidth === scrollWidth && clientHeight === scrollHeight)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)';
|
||||||
|
|
||||||
|
dom.css(this.retainerElement(), {
|
||||||
|
msTransform: translate,
|
||||||
|
transform: translate,
|
||||||
|
webkitTransform: translate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unfixScrollSize() {
|
||||||
|
var translate = 'translate(-1px, -1px)';
|
||||||
|
|
||||||
|
dom.css(this.retainerElement(), {
|
||||||
|
msTransform: translate,
|
||||||
|
transform: translate,
|
||||||
|
webkitTransform: translate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
style() {
|
||||||
|
return {
|
||||||
|
color: '#333',
|
||||||
|
cursor: 'default',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontSize: '14px',
|
||||||
|
height: '100%',
|
||||||
|
MozUserSelect: 'none',
|
||||||
|
msUserSelect: 'none',
|
||||||
|
overflow: 'visible',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
webkitUserSelect: 'none',
|
||||||
|
width: '100%',
|
||||||
|
zoom: this.zoomFactor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
retainerStyle() {
|
||||||
|
return {
|
||||||
|
height: '1px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw() {
|
||||||
|
if (this.disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var rootElement = this.rootElement();
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
rootElement = dom.body();
|
||||||
|
dom.css(rootElement, {
|
||||||
|
height: '100vh',
|
||||||
|
margin: '0',
|
||||||
|
width: '100vw'
|
||||||
|
});
|
||||||
|
this.rootElement(rootElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousElement = this.element();
|
||||||
|
var element = dom.el('<div>');
|
||||||
|
element.className = 'rw-cmap-surface';
|
||||||
|
dom.draggable(element, function(x, y, event) {
|
||||||
|
this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
|
||||||
|
}.bind(this), function(dx, dy, event) {
|
||||||
|
this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
|
||||||
|
}.bind(this), function(dx, dy, event) {
|
||||||
|
this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
|
||||||
|
}.bind(this));
|
||||||
|
this.element(element);
|
||||||
|
|
||||||
|
this.componentList().toArray().forEach(function(component) {
|
||||||
|
component.parentElement(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
var retainerElement = dom.el('<div>');
|
||||||
|
dom.css(retainerElement, this.retainerStyle());
|
||||||
|
dom.append(element, retainerElement);
|
||||||
|
this.retainerElement(retainerElement);
|
||||||
|
|
||||||
|
// set initial position of retainer
|
||||||
|
this.unfixScrollSize();
|
||||||
|
|
||||||
|
dom.css(element, this.style());
|
||||||
|
if (previousElement && previousElement.parentNode)
|
||||||
|
previousElement.parentNode.removeChild(previousElement);
|
||||||
|
dom.append(rootElement, element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmap.LINK_Z_INDEX_BASE = 100;
|
||||||
|
Cmap.NODE_Z_INDEX_BASE = 100000;
|
||||||
|
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
|
||||||
|
Cmap.CONNECTION_TYPE_SOURCE = 'source';
|
||||||
|
Cmap.CONNECTION_TYPE_TARGET = 'target';
|
||||||
|
|
||||||
|
export { Cmap as DrawingSurface };
|
||||||
@@ -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 = "racket-wiki-cmap-bundle";
|
||||||
const FORMAT_VERSION = 1;
|
const FORMAT_VERSION = 1;
|
||||||
@@ -12,10 +21,15 @@
|
|||||||
];
|
];
|
||||||
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
|
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
|
||||||
|
|
||||||
|
/** Return a detached JSON-compatible copy while preserving undefined. */
|
||||||
function clone(value) {
|
function clone(value) {
|
||||||
return value === undefined ? undefined : JSON.parse(JSON.stringify(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) {
|
function validPageReference(value) {
|
||||||
if (typeof value !== "string") return false;
|
if (typeof value !== "string") return false;
|
||||||
const separator = value.indexOf(":");
|
const separator = value.indexOf(":");
|
||||||
@@ -25,6 +39,7 @@
|
|||||||
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
|
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept only complete HTTP(S) URLs for external concept links. */
|
||||||
function validExternalUrl(value) {
|
function validExternalUrl(value) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(String(value));
|
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) {
|
function decodedDocument(value) {
|
||||||
let documentValue = value;
|
let documentValue = value;
|
||||||
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
||||||
@@ -43,6 +63,7 @@
|
|||||||
documentValue : {};
|
documentValue : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy only shared concept fields into the bundle-level concept table. */
|
||||||
function conceptContent(value) {
|
function conceptContent(value) {
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const key of CONCEPT_KEYS) {
|
for (const key of CONCEPT_KEYS) {
|
||||||
@@ -51,18 +72,21 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Index valid document concepts by their stable identity. */
|
||||||
function conceptsById(documentValue) {
|
function conceptsById(documentValue) {
|
||||||
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
|
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
|
||||||
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
|
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
|
||||||
.map((concept) => [concept.id, concept]));
|
.map((concept) => [concept.id, concept]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return unique concept ids used by non-phrase placements. */
|
||||||
function itemConceptIds(documentValue) {
|
function itemConceptIds(documentValue) {
|
||||||
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
|
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
|
||||||
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
|
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
|
||||||
.map((item) => item.conceptId))];
|
.map((item) => item.conceptId))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find linked CMap slugs in the concepts actually placed on a document. */
|
||||||
function linkedMapSlugs(documentValue) {
|
function linkedMapSlugs(documentValue) {
|
||||||
const concepts = conceptsById(documentValue);
|
const concepts = conceptsById(documentValue);
|
||||||
return [...new Set(itemConceptIds(documentValue)
|
return [...new Set(itemConceptIds(documentValue)
|
||||||
@@ -70,6 +94,11 @@
|
|||||||
.filter(Boolean))];
|
.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) {
|
function linkedPageReferences(documentValue) {
|
||||||
const references = new Set();
|
const references = new Set();
|
||||||
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
|
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
|
||||||
@@ -87,6 +116,11 @@
|
|||||||
return references;
|
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) {
|
function attachmentUrls(markdown) {
|
||||||
const source = String(markdown || "");
|
const source = String(markdown || "");
|
||||||
const urls = new Set();
|
const urls = new Set();
|
||||||
@@ -102,6 +136,7 @@
|
|||||||
other !== url && other.startsWith(`${url} `)));
|
other !== url && other.startsWith(`${url} `)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Derive a readable fallback filename from an upload URL. */
|
||||||
function attachmentName(url) {
|
function attachmentName(url) {
|
||||||
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
|
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +146,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace upload URLs after imported attachments receive new server URLs. */
|
||||||
function replaceAttachmentUrls(markdown, replacements) {
|
function replaceAttachmentUrls(markdown, replacements) {
|
||||||
let result = String(markdown || "");
|
let result = String(markdown || "");
|
||||||
const entries = replacements instanceof Map ? [...replacements.entries()] :
|
const entries = replacements instanceof Map ? [...replacements.entries()] :
|
||||||
@@ -123,6 +159,11 @@
|
|||||||
return result;
|
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) {
|
function placementDocument(documentValue) {
|
||||||
const documentCopy = clone(decodedDocument(documentValue));
|
const documentCopy = clone(decodedDocument(documentValue));
|
||||||
const ids = itemConceptIds(documentCopy);
|
const ids = itemConceptIds(documentCopy);
|
||||||
@@ -149,6 +190,10 @@
|
|||||||
return documentCopy;
|
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) {
|
async function pageRecord(page, requestedReference, loadAttachment) {
|
||||||
const markdown = String(page.markdown || "");
|
const markdown = String(page.markdown || "");
|
||||||
const attachments = [];
|
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) {
|
async function buildBundle(options) {
|
||||||
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
|
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
|
||||||
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
|
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
|
||||||
@@ -248,6 +311,18 @@
|
|||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the complete bundle contract and return the original bundle.
|
||||||
|
*
|
||||||
|
* @param {object} bundle Candidate bundle to validate.
|
||||||
|
* @returns {object} The same bundle object after successful validation.
|
||||||
|
* @throws {Error} With `validationErrors` when one or more contract checks fail.
|
||||||
|
*
|
||||||
|
* Validation also builds the concept, map, item and page-reference indexes
|
||||||
|
* needed to detect duplicate identities, dangling references and missing
|
||||||
|
* linked pages. Up to twenty errors are included in the message while the
|
||||||
|
* complete list remains available on `error.validationErrors`.
|
||||||
|
*/
|
||||||
function validateBundle(bundle) {
|
function validateBundle(bundle) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const issue = (path, message) => errors.push(`${path}: ${message}`);
|
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.concepts)) issue("concepts", "must be an array");
|
||||||
if (!Array.isArray(bundle.pages)) issue("pages", "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 conceptIds = new Set();
|
||||||
const conceptLabels = new Set();
|
const conceptLabels = new Set();
|
||||||
const usedConceptIds = new Set();
|
const usedConceptIds = new Set();
|
||||||
@@ -297,6 +373,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate map identities, placements, connectors and map-level page links.
|
||||||
const mapSlugs = new Set();
|
const mapSlugs = new Set();
|
||||||
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
|
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
|
||||||
const path = `cmaps[${mapIndex}]`;
|
const path = `cmaps[${mapIndex}]`;
|
||||||
@@ -362,6 +439,7 @@
|
|||||||
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
|
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();
|
const pageReferences = new Set();
|
||||||
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
|
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
|
||||||
const path = `pages[${index}]`;
|
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 : []);
|
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
|
||||||
for (const reference of linkedPageReferences) {
|
for (const reference of linkedPageReferences) {
|
||||||
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
|
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) {
|
if (errors.length) {
|
||||||
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
|
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
|
||||||
error.validationErrors = errors;
|
error.validationErrors = errors;
|
||||||
@@ -420,6 +500,14 @@
|
|||||||
return bundle;
|
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) {
|
function preparedMapDocument(bundle, cmap) {
|
||||||
validateBundle(bundle);
|
validateBundle(bundle);
|
||||||
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
|
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-23
@@ -196,10 +196,27 @@
|
|||||||
<div class="cmap-workspace">
|
<div class="cmap-workspace">
|
||||||
<aside id="cmap-selection-toolbar" class="cmap-selection-toolbar editor-only" aria-label="Selection and layout tools" data-tr-aria-label="selection-layout-tools">
|
<aside id="cmap-selection-toolbar" class="cmap-selection-toolbar editor-only" aria-label="Selection and layout tools" data-tr-aria-label="selection-layout-tools">
|
||||||
<div class="cmap-selection-tool-group" role="group" aria-label="Selection tools" data-tr-aria-label="selection-tools">
|
<div class="cmap-selection-tool-group" role="group" aria-label="Selection tools" data-tr-aria-label="selection-tools">
|
||||||
<button type="button" data-cmap-selection-action="edit" title="Edit selected" data-tr-title="edit-selected-concept" aria-label="Edit selected" data-tr-aria-label="edit-selected-concept" disabled><i data-lucide="pencil" aria-hidden="true"></i></button>
|
<button id="cmap-edit-selected" type="button" data-cmap-selection-action="edit" title="Edit selected" data-tr-title="edit-selected-concept" aria-label="Edit selected" data-tr-aria-label="edit-selected-concept" disabled><i data-lucide="pencil" aria-hidden="true"></i></button>
|
||||||
<button type="button" data-cmap-selection-action="group" title="Group selection as sub-CMap" data-tr-title="group-selected" aria-label="Group selection as sub-CMap" data-tr-aria-label="group-selected" disabled><i data-lucide="group" aria-hidden="true"></i></button>
|
<button id="cmap-group-selected" type="button" data-cmap-selection-action="group" title="Group selection as sub-CMap" data-tr-title="group-selected" aria-label="Group selection as sub-CMap" data-tr-aria-label="group-selected" disabled><i data-lucide="group" aria-hidden="true"></i></button>
|
||||||
<button type="button" data-cmap-selection-action="ungroup" title="Detach from sub-CMap" data-tr-title="ungroup-selected" aria-label="Detach from sub-CMap" data-tr-aria-label="ungroup-selected" disabled><i data-lucide="ungroup" aria-hidden="true"></i></button>
|
<button id="cmap-ungroup-selected" type="button" data-cmap-selection-action="ungroup" title="Detach from sub-CMap" data-tr-title="ungroup-selected" aria-label="Detach from sub-CMap" data-tr-aria-label="ungroup-selected" disabled><i data-lucide="ungroup" aria-hidden="true"></i></button>
|
||||||
<button type="button" data-cmap-selection-action="hide" title="Hide in this view" data-tr-title="hide-in-this-view" aria-label="Hide in this view" data-tr-aria-label="hide-in-this-view" disabled><i data-lucide="eye-off" aria-hidden="true"></i></button>
|
<button id="cmap-hide-selected" type="button" data-cmap-selection-action="hide" title="Hide in this view" data-tr-title="hide-in-this-view" aria-label="Hide in this view" data-tr-aria-label="hide-in-this-view" disabled><i data-lucide="eye-off" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-cut-selected" type="button" title="Cut concept reference" data-tr-title="cut-linked-concepts" aria-label="Cut concept reference" data-tr-aria-label="cut-linked-concepts" disabled><i data-lucide="scissors" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-copy-selected" type="button" title="Copy concept reference" data-tr-title="copy-linked-concepts" aria-label="Copy concept reference" data-tr-aria-label="copy-linked-concepts" disabled><i data-lucide="copy" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-paste-concepts" type="button" title="Paste linked concept" data-tr-title="paste-linked-concepts" aria-label="Paste linked concept" data-tr-aria-label="paste-linked-concepts" disabled><i data-lucide="clipboard" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-delete-selected" class="danger" type="button" title="Delete selection" data-tr-title="delete-selected" aria-label="Delete selection" data-tr-aria-label="delete-selected" disabled><i data-lucide="trash-2" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
||||||
|
<div class="cmap-selection-tool-group" role="group" aria-label="Creation tools" data-tr-aria-label="creation-tools">
|
||||||
|
<button id="cmap-add-concept" type="button" title="Add concept" data-tr-title="add-concept" aria-label="Add concept" data-tr-aria-label="add-concept"><i data-lucide="plus" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-add-page" type="button" title="Add current page" data-tr-title="add-page-node" aria-label="Add current page" data-tr-aria-label="add-page-node"><i data-lucide="file-plus-2" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-add-submap" type="button" title="Add sub-CMap" data-tr-title="add-submap" aria-label="Add sub-CMap" data-tr-aria-label="add-submap"><i data-lucide="folder-plus" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-promote-submap" type="button" title="Make separate CMap" data-tr-title="promote-submap" aria-label="Make separate CMap" data-tr-aria-label="promote-submap" disabled><i data-lucide="external-link" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
||||||
|
<div class="cmap-selection-tool-group" role="group" aria-label="History and selection tools" data-tr-aria-label="history-selection-tools">
|
||||||
|
<button id="cmap-undo" type="button" title="Undo" data-tr-title="undo" aria-label="Undo" data-tr-aria-label="undo"><i data-lucide="undo-2" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-redo" type="button" title="Redo" data-tr-title="redo-action" aria-label="Redo" data-tr-aria-label="redo-action" disabled><i data-lucide="redo-2" aria-hidden="true"></i></button>
|
||||||
|
<button id="cmap-select-all" type="button" title="Select all" data-tr-title="select-all" aria-label="Select all" data-tr-aria-label="select-all"><i data-lucide="scan" aria-hidden="true"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
||||||
<div class="cmap-selection-tool-group" role="group" aria-label="Size tools" data-tr-aria-label="size-tools">
|
<div class="cmap-selection-tool-group" role="group" aria-label="Size tools" data-tr-aria-label="size-tools">
|
||||||
@@ -236,24 +253,6 @@
|
|||||||
<button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button>
|
<button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button>
|
||||||
<button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button>
|
<button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button>
|
||||||
<div class="cmap-context-separator" role="separator"></div>
|
<div class="cmap-context-separator" role="separator"></div>
|
||||||
<button id="cmap-add-concept" type="button" role="menuitem" data-tr="add-concept">Add concept</button>
|
|
||||||
<button id="cmap-add-page" type="button" role="menuitem" data-tr="add-page-node">Add current page</button>
|
|
||||||
<button id="cmap-add-submap" type="button" role="menuitem" data-tr="add-submap">Add sub-CMap</button>
|
|
||||||
<button id="cmap-promote-submap" type="button" role="menuitem" data-tr="promote-submap" disabled>Make separate CMap</button>
|
|
||||||
<button id="cmap-edit-selected" type="button" role="menuitem" data-tr="edit-selected-concept">Edit selected</button>
|
|
||||||
<button id="cmap-cut-selected" type="button" role="menuitem" data-tr="cut-linked-concepts" disabled>Cut concept reference</button>
|
|
||||||
<button id="cmap-copy-selected" type="button" role="menuitem" data-tr="copy-linked-concepts">Copy concept reference</button>
|
|
||||||
<button id="cmap-paste-concepts" type="button" role="menuitem" data-tr="paste-linked-concepts" disabled>Paste linked concept</button>
|
|
||||||
<div class="cmap-context-separator" role="separator"></div>
|
|
||||||
<button id="cmap-undo" type="button" role="menuitem" data-tr="undo" disabled>Undo</button>
|
|
||||||
<button id="cmap-redo" type="button" role="menuitem" data-tr="redo-action" disabled>Redo</button>
|
|
||||||
<div class="cmap-context-separator" role="separator"></div>
|
|
||||||
<button id="cmap-select-all" type="button" role="menuitem" data-tr="select-all">Select all</button>
|
|
||||||
<button id="cmap-group-selected" type="button" role="menuitem" data-tr="group-selected" disabled>Group selection as sub-CMap</button>
|
|
||||||
<button id="cmap-ungroup-selected" type="button" role="menuitem" data-tr="ungroup-selected" disabled>Detach from sub-CMap</button>
|
|
||||||
<button id="cmap-hide-selected" type="button" role="menuitem" data-tr="hide-in-this-view" disabled>Hide in this view</button>
|
|
||||||
<button id="cmap-delete-selected" class="danger" type="button" role="menuitem" data-tr="delete-selected" disabled>Delete selection</button>
|
|
||||||
<div class="cmap-context-separator" role="separator"></div>
|
|
||||||
<button id="cmap-toggle-page-guides" type="button" role="menuitemcheckbox" aria-checked="true" data-tr="a4-page-boundaries">A4 page boundaries</button>
|
<button id="cmap-toggle-page-guides" type="button" role="menuitemcheckbox" aria-checked="true" data-tr="a4-page-boundaries">A4 page boundaries</button>
|
||||||
<button id="cmap-reset" type="button" role="menuitem" data-tr="reload-concept-map">Reload CMap</button>
|
<button id="cmap-reset" type="button" role="menuitem" data-tr="reload-concept-map">Reload CMap</button>
|
||||||
<div class="cmap-context-separator" role="separator"></div>
|
<div class="cmap-context-separator" role="separator"></div>
|
||||||
@@ -630,13 +629,29 @@
|
|||||||
<script src="/vendor/easymde.min.js"></script>
|
<script src="/vendor/easymde.min.js"></script>
|
||||||
<script src="/vendor/lucide.min.js"></script>
|
<script src="/vendor/lucide.min.js"></script>
|
||||||
<script src="/vendor/diff2html-ui-base.min.js"></script>
|
<script src="/vendor/diff2html-ui-base.min.js"></script>
|
||||||
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
|
|
||||||
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"/cmap/cmap-racket-wiki.js": "/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
|
"/cmap/cmap-racket-wiki.js": "/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/cmap-utils.js": "/cmap/cmap-utils.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
|
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/cmap.js": "/cmap/cmap.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/controller/cmap-history.js": "/cmap/controller/cmap-history.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/controller/cmap-interaction-controller.js": "/cmap/controller/cmap-interaction-controller.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/controller/cmap-selection-controller.js": "/cmap/controller/cmap-selection-controller.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/controller/cmap-submap-controller.js": "/cmap/controller/cmap-submap-controller.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/diagram-component.js": "/cmap/engine/diagram-component.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/diagram-engine.js": "/cmap/engine/diagram-engine.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/diagram-group.js": "/cmap/engine/diagram-group.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/diagram-link.js": "/cmap/engine/diagram-link.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/diagram-node.js": "/cmap/engine/diagram-node.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/drawing-collections.js": "/cmap/engine/drawing-collections.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/drawing-components.js": "/cmap/engine/drawing-components.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/drawing-core.js": "/cmap/engine/drawing-core.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/drawing-relations.js": "/cmap/engine/drawing-relations.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/drawing-support.js": "/cmap/engine/drawing-support.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/engine/drawing-surface.js": "/cmap/engine/drawing-surface.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/model/concept-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
|
"/cmap/model/concept-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
|
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/model/appearance.js": "/cmap/model/appearance.js?id=__CMAP_CACHE_ID__",
|
"/cmap/model/appearance.js": "/cmap/model/appearance.js?id=__CMAP_CACHE_ID__",
|
||||||
@@ -648,6 +663,7 @@
|
|||||||
"/cmap/model/markdown-exporter.js": "/cmap/model/markdown-exporter.js?id=__CMAP_CACHE_ID__",
|
"/cmap/model/markdown-exporter.js": "/cmap/model/markdown-exporter.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/model/settings-repository.js": "/cmap/model/settings-repository.js?id=__CMAP_CACHE_ID__",
|
"/cmap/model/settings-repository.js": "/cmap/model/settings-repository.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/view/appearance-editor.js": "/cmap/view/appearance-editor.js?id=__CMAP_CACHE_ID__",
|
"/cmap/view/appearance-editor.js": "/cmap/view/appearance-editor.js?id=__CMAP_CACHE_ID__",
|
||||||
|
"/cmap/view/cmap-item-decorator.js": "/cmap/view/cmap-item-decorator.js?id=__CMAP_CACHE_ID__",
|
||||||
"/js/wiki/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
|
"/js/wiki/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
|
||||||
"/js/wiki/routes.js": "/js/wiki/routes.js?id=__CMAP_CACHE_ID__",
|
"/js/wiki/routes.js": "/js/wiki/routes.js?id=__CMAP_CACHE_ID__",
|
||||||
"/js/wiki/markdown.js": "/js/wiki/markdown.js?id=__CMAP_CACHE_ID__",
|
"/js/wiki/markdown.js": "/js/wiki/markdown.js?id=__CMAP_CACHE_ID__",
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export class CmapWorkspace {
|
|||||||
viewport.append(canvas);
|
viewport.append(canvas);
|
||||||
embed.append(header, viewport);
|
embed.append(header, viewport);
|
||||||
const editor = window.RacketWikiCmap.createEditor(canvas, {
|
const editor = window.RacketWikiCmap.createEditor(canvas, {
|
||||||
Cmap: window.Cmap,
|
|
||||||
renderItem: (record) => cmapNodeHtml(record),
|
renderItem: (record) => cmapNodeHtml(record),
|
||||||
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
|
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
|
||||||
});
|
});
|
||||||
@@ -349,6 +348,22 @@ export class CmapWorkspace {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLinkedCmap(record) {
|
||||||
|
if (!record || !record.cmapSlug) return false;
|
||||||
|
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openParentCmap() {
|
||||||
|
const source = state.currentConceptMapSource;
|
||||||
|
if (source && source.slug) {
|
||||||
|
navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const prototype = cmapPrototypeState();
|
||||||
|
return Boolean(prototype.editor && prototype.editor.openParentMap());
|
||||||
|
}
|
||||||
|
|
||||||
function titledCmapComboboxEntry(record) {
|
function titledCmapComboboxEntry(record) {
|
||||||
const label = record.title || record.slug;
|
const label = record.title || record.slug;
|
||||||
return {
|
return {
|
||||||
@@ -726,7 +741,6 @@ export class CmapWorkspace {
|
|||||||
updateCmapSelectionToolbar(null, [], null);
|
updateCmapSelectionToolbar(null, [], null);
|
||||||
|
|
||||||
console.info("[racket-wiki:cmap-host 0.2.122] resetCmapPrototype", {
|
console.info("[racket-wiki:cmap-host 0.2.122] resetCmapPrototype", {
|
||||||
cmapAvailable: typeof window.Cmap === "function",
|
|
||||||
interactionLayerAvailable: Boolean(window.RacketWikiCmap),
|
interactionLayerAvailable: Boolean(window.RacketWikiCmap),
|
||||||
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
|
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
|
||||||
cmapStylesheet: Array.from(document.styleSheets)
|
cmapStylesheet: Array.from(document.styleSheets)
|
||||||
@@ -734,7 +748,7 @@ export class CmapWorkspace {
|
|||||||
.find((href) => href && href.includes("/cmap/cmap.css")) || null
|
.find((href) => href && href.includes("/cmap/cmap.css")) || null
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof window.Cmap !== "function" || !window.RacketWikiCmap) {
|
if (!window.RacketWikiCmap) {
|
||||||
const message = document.createElement("p");
|
const message = document.createElement("p");
|
||||||
message.className = "error";
|
message.className = "error";
|
||||||
message.textContent = "The bundled cmap component could not be loaded.";
|
message.textContent = "The bundled cmap component could not be loaded.";
|
||||||
@@ -744,7 +758,6 @@ export class CmapWorkspace {
|
|||||||
|
|
||||||
const prototype = cmapPrototypeState();
|
const prototype = cmapPrototypeState();
|
||||||
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
|
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
|
||||||
Cmap: window.Cmap,
|
|
||||||
renderItem: (record) => cmapNodeHtml(record),
|
renderItem: (record) => cmapNodeHtml(record),
|
||||||
onOpenPage: (record) => {
|
onOpenPage: (record) => {
|
||||||
if (record.pageSlug) {
|
if (record.pageSlug) {
|
||||||
@@ -752,9 +765,10 @@ export class CmapWorkspace {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenCmap: (record) => {
|
onOpenCmap: (record) => {
|
||||||
if (record.cmapSlug) {
|
openLinkedCmap(record);
|
||||||
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
|
},
|
||||||
}
|
onOpenParentCmap: () => {
|
||||||
|
openParentCmap();
|
||||||
},
|
},
|
||||||
onOpenExternalUrl: (record) => openCmapExternalUrl(record),
|
onOpenExternalUrl: (record) => openCmapExternalUrl(record),
|
||||||
onOpenStoredSubMap: (record) => {
|
onOpenStoredSubMap: (record) => {
|
||||||
@@ -1699,7 +1713,7 @@ export class CmapWorkspace {
|
|||||||
markCurrentCmapSaved();
|
markCurrentCmapSaved();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$("cmap-map-back").addEventListener("click", () => {
|
const navigateToParentCmap = () => {
|
||||||
const prototype = cmapPrototypeState();
|
const prototype = cmapPrototypeState();
|
||||||
if (prototype.editor && prototype.editor.canStepBackWithinMap()) {
|
if (prototype.editor && prototype.editor.canStepBackWithinMap()) {
|
||||||
prototype.editor.openParentMap();
|
prototype.editor.openParentMap();
|
||||||
@@ -1711,7 +1725,8 @@ export class CmapWorkspace {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (prototype.editor) prototype.editor.openParentMap();
|
if (prototype.editor) prototype.editor.openParentMap();
|
||||||
});
|
};
|
||||||
|
$("cmap-map-navigation").addEventListener("click", navigateToParentCmap);
|
||||||
$("cmap-canvas").addEventListener("contextmenu", (event) => {
|
$("cmap-canvas").addEventListener("contextmenu", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const prototype = cmapPrototypeState();
|
const prototype = cmapPrototypeState();
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function fakeLink(attributes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadEditorFactory() {
|
async function loadEditorFactory() {
|
||||||
|
const previousFactory = global.window && global.window.RacketWikiCmap;
|
||||||
global.Element = class Element {};
|
global.Element = class Element {};
|
||||||
global.document = {
|
global.document = {
|
||||||
currentScript: null,
|
currentScript: null,
|
||||||
@@ -83,12 +84,12 @@ async function loadEditorFactory() {
|
|||||||
createElement: () => fakeElement()
|
createElement: () => fakeElement()
|
||||||
};
|
};
|
||||||
global.window = {
|
global.window = {
|
||||||
Cmap: null,
|
|
||||||
setTimeout,
|
setTimeout,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
requestAnimationFrame: (callback) => callback()
|
requestAnimationFrame: (callback) => callback()
|
||||||
};
|
};
|
||||||
await import("../static/cmap/cmap-racket-wiki.js");
|
await import("../static/cmap/cmap-racket-wiki.js");
|
||||||
|
global.window.RacketWikiCmap = global.window.RacketWikiCmap || previousFactory;
|
||||||
return global.window.RacketWikiCmap;
|
return global.window.RacketWikiCmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ test("a dragged connector endpoint survives collapsing and expanding its submap"
|
|||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
isConnected: true
|
isConnected: true
|
||||||
};
|
};
|
||||||
const editor = factory.createEditor(canvas, { Cmap: () => map });
|
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
const submap = editor.addItem({
|
const submap = editor.addItem({
|
||||||
id: 1,
|
id: 1,
|
||||||
kind: "submap",
|
kind: "submap",
|
||||||
@@ -173,3 +174,47 @@ test("a dragged connector endpoint survives collapsing and expanding its submap"
|
|||||||
lineWidth: 2
|
lineWidth: 2
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("moving a concept also moves connected concepts that link to another CMap", async () => {
|
||||||
|
const factory = await loadEditorFactory();
|
||||||
|
const map = {
|
||||||
|
onSelection() {},
|
||||||
|
onActivation() {},
|
||||||
|
node: (attributes) => fakeNode(attributes),
|
||||||
|
link: (attributes) => fakeLink(attributes)
|
||||||
|
};
|
||||||
|
const canvas = {
|
||||||
|
addEventListener() {},
|
||||||
|
querySelector: () => null,
|
||||||
|
clientWidth: 1200,
|
||||||
|
clientHeight: 800,
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
isConnected: true
|
||||||
|
};
|
||||||
|
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
|
const owner = editor.addItem({ id: 1, label: "Owner", x: 100, y: 100 });
|
||||||
|
const phrase = editor.addItem({ id: 2, kind: "phrase", label: "relates to", x: 260, y: 100 });
|
||||||
|
const linked = editor.addItem({
|
||||||
|
id: 3,
|
||||||
|
label: "Linked CMap concept",
|
||||||
|
cmapSlug: "other-map",
|
||||||
|
x: 420,
|
||||||
|
y: 100
|
||||||
|
});
|
||||||
|
editor.addConnector(owner, phrase, true, { id: 1 });
|
||||||
|
editor.addConnector(phrase, linked, true, { id: 2 });
|
||||||
|
|
||||||
|
editor.selectItem(owner);
|
||||||
|
editor.handleItemMove(owner, 180, 140);
|
||||||
|
|
||||||
|
assert.deepEqual({
|
||||||
|
owner: [owner.node.attr("x"), owner.node.attr("y")],
|
||||||
|
phrase: [phrase.node.attr("x"), phrase.node.attr("y")],
|
||||||
|
linked: [linked.node.attr("x"), linked.node.attr("y")]
|
||||||
|
}, {
|
||||||
|
owner: [180, 140],
|
||||||
|
phrase: [340, 140],
|
||||||
|
linked: [500, 140]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async function loadEditorFactory() {
|
|||||||
body: { append() {} },
|
body: { append() {} },
|
||||||
createElement: () => fakeElement()
|
createElement: () => fakeElement()
|
||||||
};
|
};
|
||||||
global.window = { Cmap: null };
|
global.window = {};
|
||||||
await import("../static/cmap/cmap-racket-wiki.js");
|
await import("../static/cmap/cmap-racket-wiki.js");
|
||||||
return global.window.RacketWikiCmap;
|
return global.window.RacketWikiCmap;
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ test("automatic render sizing advances only an unchanged saved baseline", async
|
|||||||
let savedBaseline = null;
|
let savedBaseline = null;
|
||||||
let notification = null;
|
let notification = null;
|
||||||
const editor = factory.createEditor(canvas, {
|
const editor = factory.createEditor(canvas, {
|
||||||
Cmap: () => map,
|
createDiagramEngine: () => map,
|
||||||
onAutomaticLayoutChange: (change) => {
|
onAutomaticLayoutChange: (change) => {
|
||||||
notification = change;
|
notification = change;
|
||||||
if (savedBaseline === change.beforeSnapshot) {
|
if (savedBaseline === change.beforeSnapshot) {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { DiagramGroup } from "../static/cmap/engine/diagram-group.js";
|
||||||
|
import { DiagramComponent } from "../static/cmap/engine/diagram-component.js";
|
||||||
|
import { DiagramEngine } from "../static/cmap/engine/diagram-engine.js";
|
||||||
|
|
||||||
|
function component(engine, attributes) {
|
||||||
|
const state = { ...attributes };
|
||||||
|
const drawing = {
|
||||||
|
visible: true,
|
||||||
|
redraw() {},
|
||||||
|
x: () => state.x,
|
||||||
|
y: () => state.y,
|
||||||
|
width: () => state.width,
|
||||||
|
height: () => state.height
|
||||||
|
};
|
||||||
|
return new DiagramComponent(engine, drawing, ["x", "y", "width", "height"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("DiagramGroup calculates bounds for nodes and nested groups", () => {
|
||||||
|
const engine = { surfaceElement: () => null, applyFilter: (handle) => {
|
||||||
|
handle.component.visible = handle.baseVisible;
|
||||||
|
handle.component.redraw();
|
||||||
|
}};
|
||||||
|
const first = component(engine, { x: 10, y: 20, width: 100, height: 40 });
|
||||||
|
const second = component(engine, { x: 150, y: 80, width: 50, height: 30 });
|
||||||
|
const group = new DiagramGroup(engine, { padding: 10 });
|
||||||
|
const nested = new DiagramGroup(engine, { padding: 5 });
|
||||||
|
|
||||||
|
nested.add(second);
|
||||||
|
group.add(first).add(nested);
|
||||||
|
|
||||||
|
assert.deepEqual(group.bounds(), { left: 0, top: 10, right: 215, bottom: 125 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DiagramGroup collapse controls member visibility", () => {
|
||||||
|
const engine = { surfaceElement: () => null, applyFilter: (handle) => {
|
||||||
|
handle.component.visible = handle.baseVisible;
|
||||||
|
handle.component.redraw();
|
||||||
|
}};
|
||||||
|
const item = component(engine, { x: 0, y: 0, width: 20, height: 20 });
|
||||||
|
const group = new DiagramGroup(engine);
|
||||||
|
group.add(item);
|
||||||
|
|
||||||
|
assert.equal(group.setExpanded(false), false);
|
||||||
|
assert.equal(item.visible(), false);
|
||||||
|
assert.equal(group.setExpanded(true), true);
|
||||||
|
assert.equal(item.visible(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DiagramGroup updates frame appearance through its public API", () => {
|
||||||
|
const engine = { surfaceElement: () => null };
|
||||||
|
const group = new DiagramGroup(engine, { backgroundColor: "white", borderColor: "black" });
|
||||||
|
|
||||||
|
group.setAppearance({ label: "Nested", backgroundColor: "green", borderColor: "blue" });
|
||||||
|
|
||||||
|
assert.equal(group.label, "Nested");
|
||||||
|
assert.equal(group.backgroundColor, "green");
|
||||||
|
assert.equal(group.borderColor, "blue");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nested DiagramGroups keep child frames above parent frames", () => {
|
||||||
|
const engine = {
|
||||||
|
surfaceElement: () => null,
|
||||||
|
applyFilter: (handle) => {
|
||||||
|
handle.component.visible = handle.baseVisible;
|
||||||
|
handle.component.redraw();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const item = component(engine, { x: 20, y: 20, width: 40, height: 30 });
|
||||||
|
const child = new DiagramGroup(engine, { depth: 1 });
|
||||||
|
const parent = new DiagramGroup(engine, { depth: 0 });
|
||||||
|
child.add(item);
|
||||||
|
parent.add(child);
|
||||||
|
|
||||||
|
assert.equal(child.depth > parent.depth, true);
|
||||||
|
assert.equal(parent.bounds().left, -28);
|
||||||
|
assert.equal(child.bounds().left, -4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DiagramEngine combines base visibility with an external filter", () => {
|
||||||
|
const engine = Object.create(DiagramEngine.prototype);
|
||||||
|
engine.filter = null;
|
||||||
|
engine.handles = new Map();
|
||||||
|
engine.groups = new Set();
|
||||||
|
const item = component(engine, { x: 0, y: 0, width: 20, height: 20 });
|
||||||
|
engine.handles.set(item.component, item);
|
||||||
|
|
||||||
|
engine.setFilter((handle) => handle === item);
|
||||||
|
assert.equal(item.visible(), true);
|
||||||
|
engine.setFilter(() => false);
|
||||||
|
assert.equal(item.visible(), false);
|
||||||
|
item.visible(true);
|
||||||
|
assert.equal(item.visible(), false);
|
||||||
|
engine.setFilter(null);
|
||||||
|
assert.equal(item.visible(), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { CmapHistory } from "../static/cmap/controller/cmap-history.js";
|
||||||
|
|
||||||
|
function history(initial = "one") {
|
||||||
|
let value = initial;
|
||||||
|
const changes = [];
|
||||||
|
const instance = new CmapHistory({
|
||||||
|
snapshot: () => value,
|
||||||
|
restore: (snapshot) => { value = snapshot; },
|
||||||
|
onChange: (change) => changes.push(change),
|
||||||
|
limit: 2
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
instance,
|
||||||
|
changes,
|
||||||
|
get value() { return value; },
|
||||||
|
set value(next) { value = next; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CmapHistory commits, undoes and redoes serialized state", () => {
|
||||||
|
const state = history();
|
||||||
|
state.instance.reset();
|
||||||
|
state.value = "two";
|
||||||
|
assert.equal(state.instance.commit(), true);
|
||||||
|
state.value = "three";
|
||||||
|
assert.equal(state.instance.commit(), true);
|
||||||
|
|
||||||
|
assert.equal(state.instance.undo(), true);
|
||||||
|
assert.equal(state.value, "two");
|
||||||
|
assert.equal(state.instance.redo(), true);
|
||||||
|
assert.equal(state.value, "three");
|
||||||
|
assert.equal(state.instance.canUndo(), true);
|
||||||
|
assert.equal(state.instance.canRedo(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CmapHistory replaces a document as one undo operation", () => {
|
||||||
|
const state = history();
|
||||||
|
state.instance.reset();
|
||||||
|
state.instance.replace(() => { state.value = "replacement"; });
|
||||||
|
|
||||||
|
assert.equal(state.value, "replacement");
|
||||||
|
assert.equal(state.instance.undo(), true);
|
||||||
|
assert.equal(state.value, "one");
|
||||||
|
assert.ok(state.changes.length > 0);
|
||||||
|
});
|
||||||
@@ -88,9 +88,10 @@ test("relations render and hit-test behind concept nodes", async () => {
|
|||||||
requestAnimationFrame: (callback) => setImmediate(callback)
|
requestAnimationFrame: (callback) => setImmediate(callback)
|
||||||
};
|
};
|
||||||
|
|
||||||
await import("../static/cmap/cmap.js");
|
const { DiagramEngine } = await import("../static/cmap/cmap.js");
|
||||||
const Cmap = global.window.Cmap;
|
assert.equal(global.window.Cmap, undefined,
|
||||||
const map = Cmap(root);
|
"the ES-module engine does not install a legacy global");
|
||||||
|
const map = new DiagramEngine(root);
|
||||||
const concept = map.node({ x: 40, y: 0, width: 120, height: 50 });
|
const concept = map.node({ x: 40, y: 0, width: 120, height: 50 });
|
||||||
const otherConcept = map.node({ x: 220, y: 0, width: 120, height: 50 });
|
const otherConcept = map.node({ x: 220, y: 0, width: 120, height: 50 });
|
||||||
const relation = map.link({
|
const relation = map.link({
|
||||||
@@ -98,6 +99,9 @@ test("relations render and hit-test behind concept nodes", async () => {
|
|||||||
cx: 150, cy: 25,
|
cx: 150, cy: 25,
|
||||||
targetX: 360, targetY: 25
|
targetX: 360, targetY: 25
|
||||||
});
|
});
|
||||||
|
relation.sourceNode(concept).targetNode(otherConcept).straighten();
|
||||||
|
assert.equal(relation.sourceNode(), concept);
|
||||||
|
assert.equal(relation.targetNode(), otherConcept);
|
||||||
await settleRendering();
|
await settleRendering();
|
||||||
|
|
||||||
relation.toFront();
|
relation.toFront();
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ async function loadEditorFactory() {
|
|||||||
createElement: () => ({ style: {}, remove() {} })
|
createElement: () => ({ style: {}, remove() {} })
|
||||||
};
|
};
|
||||||
global.window = {
|
global.window = {
|
||||||
Cmap: null,
|
|
||||||
setTimeout,
|
setTimeout,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
requestAnimationFrame: (callback) => callback()
|
requestAnimationFrame: (callback) => callback()
|
||||||
@@ -46,7 +45,7 @@ test("the last selected concept determines target size and alignment", async ()
|
|||||||
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
||||||
isConnected: true
|
isConnected: true
|
||||||
};
|
};
|
||||||
const editor = factory.createEditor(canvas, { Cmap: () => map });
|
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
const first = editor.addItem({
|
const first = editor.addItem({
|
||||||
id: 1, label: "First", externalUrl: "https://example.com/first",
|
id: 1, label: "First", externalUrl: "https://example.com/first",
|
||||||
x: 10, y: 10, width: 100, height: 40
|
x: 10, y: 10, width: 100, height: 40
|
||||||
@@ -65,7 +64,7 @@ test("the last selected concept determines target size and alignment", async ()
|
|||||||
"https://example.com/first",
|
"https://example.com/first",
|
||||||
"external links are serialized as shared concept content"
|
"external links are serialized as shared concept content"
|
||||||
);
|
);
|
||||||
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
|
const reloaded = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
reloaded.loadModel(editor.currentModel());
|
reloaded.loadModel(editor.currentModel());
|
||||||
assert.equal(
|
assert.equal(
|
||||||
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
|
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
|
||||||
|
|||||||
@@ -3,21 +3,38 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
let editorFactory;
|
||||||
|
|
||||||
function fakeNode(attributes) {
|
function fakeNode(attributes) {
|
||||||
const state = { ...attributes };
|
const state = { ...attributes, visible: true };
|
||||||
return {
|
return {
|
||||||
attr(value) {
|
attr(value) {
|
||||||
if (typeof value === "string") return state[value];
|
if (typeof value === "string") return state[value];
|
||||||
Object.assign(state, value);
|
Object.assign(state, value);
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
onRendered() {}, onMove() {}, onMoveEnd() {}, visible() {}, redraw() {},
|
onRendered() {}, onMove() {}, onMoveEnd() {},
|
||||||
|
visible(value) {
|
||||||
|
if (value === undefined) return state.visible;
|
||||||
|
state.visible = Boolean(value);
|
||||||
|
return state.visible;
|
||||||
|
},
|
||||||
|
redraw() {},
|
||||||
toFront() {}, remove() {}, element: () => null
|
toFront() {}, remove() {}, element: () => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEditorFactory() {
|
async function loadEditorFactory() {
|
||||||
global.Element = class Element {};
|
if (editorFactory) return editorFactory;
|
||||||
|
global.Element = class Element {
|
||||||
|
constructor() {
|
||||||
|
this.tagName = "DIV";
|
||||||
|
this.id = "";
|
||||||
|
this.classList = [];
|
||||||
|
this.dataset = {};
|
||||||
|
}
|
||||||
|
closest() { return null; }
|
||||||
|
};
|
||||||
global.document = {
|
global.document = {
|
||||||
currentScript: null,
|
currentScript: null,
|
||||||
styleSheets: [],
|
styleSheets: [],
|
||||||
@@ -25,13 +42,13 @@ async function loadEditorFactory() {
|
|||||||
createElement: () => ({ style: {}, remove() {} })
|
createElement: () => ({ style: {}, remove() {} })
|
||||||
};
|
};
|
||||||
global.window = {
|
global.window = {
|
||||||
Cmap: null,
|
|
||||||
setTimeout,
|
setTimeout,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
requestAnimationFrame: (callback) => callback()
|
requestAnimationFrame: (callback) => callback()
|
||||||
};
|
};
|
||||||
await import("../static/cmap/cmap-racket-wiki.js");
|
await import("../static/cmap/cmap-racket-wiki.js");
|
||||||
return global.window.RacketWikiCmap;
|
editorFactory = global.window.RacketWikiCmap;
|
||||||
|
return editorFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("back from a second nested submap returns exactly one map level", async () => {
|
test("back from a second nested submap returns exactly one map level", async () => {
|
||||||
@@ -46,7 +63,7 @@ test("back from a second nested submap returns exactly one map level", async ()
|
|||||||
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
||||||
isConnected: true
|
isConnected: true
|
||||||
};
|
};
|
||||||
const editor = factory.createEditor(canvas, { Cmap: () => map });
|
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
const first = editor.addItem({
|
const first = editor.addItem({
|
||||||
id: 1, kind: "submap", label: "First level", separateMap: true,
|
id: 1, kind: "submap", label: "First level", separateMap: true,
|
||||||
mapReference: { id: "first", title: "First level" },
|
mapReference: { id: "first", title: "First level" },
|
||||||
@@ -72,3 +89,168 @@ test("back from a second nested submap returns exactly one map level", async ()
|
|||||||
assert.equal(editor.activeMapRoot, first);
|
assert.equal(editor.activeMapRoot, first);
|
||||||
assert.equal(editor.canStepBackWithinMap(), false);
|
assert.equal(editor.canStepBackWithinMap(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("single-click selects and double-click edits every kind of concept", async () => {
|
||||||
|
const factory = await loadEditorFactory();
|
||||||
|
const map = {
|
||||||
|
onSelection() {},
|
||||||
|
onActivation() {},
|
||||||
|
node: (attributes) => fakeNode(attributes)
|
||||||
|
};
|
||||||
|
const canvas = {
|
||||||
|
addEventListener() {},
|
||||||
|
querySelector: () => null,
|
||||||
|
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
||||||
|
isConnected: true
|
||||||
|
};
|
||||||
|
const edited = [];
|
||||||
|
const openedPages = [];
|
||||||
|
const openedMaps = [];
|
||||||
|
const editor = factory.createEditor(canvas, {
|
||||||
|
createDiagramEngine: () => map,
|
||||||
|
onEditItem: (record) => edited.push(record),
|
||||||
|
onOpenPage: (record) => openedPages.push(record),
|
||||||
|
onOpenCmap: (record) => openedMaps.push(record)
|
||||||
|
});
|
||||||
|
const ordinary = editor.addItem({ id: 1, label: "Ordinary" });
|
||||||
|
const page = editor.addItem({ id: 2, label: "Page", pageSlug: "page" });
|
||||||
|
const linkedMap = editor.addItem({ id: 3, label: "Linked map", cmapSlug: "other" });
|
||||||
|
const submap = editor.addItem({ id: 4, kind: "submap", label: "Group" });
|
||||||
|
|
||||||
|
editor.handleMapSelection(linkedMap.node, {
|
||||||
|
target: new global.Element(),
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false
|
||||||
|
});
|
||||||
|
assert.equal(editor.selected(), linkedMap);
|
||||||
|
assert.deepEqual(openedMaps, []);
|
||||||
|
|
||||||
|
editor.handleMapActivation(ordinary.node, { preventDefault() {} });
|
||||||
|
editor.handleMapActivation(page.node, { preventDefault() {} });
|
||||||
|
editor.handleMapActivation(linkedMap.node, { preventDefault() {} });
|
||||||
|
editor.handleMapActivation(submap.node, { preventDefault() {} });
|
||||||
|
|
||||||
|
assert.deepEqual(edited, [ordinary, page, linkedMap, submap]);
|
||||||
|
assert.deepEqual(openedPages, []);
|
||||||
|
assert.deepEqual(openedMaps, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a linked CMap opens only through its arrow decorator", async () => {
|
||||||
|
await loadEditorFactory();
|
||||||
|
const { CmapItemDecorator } = await import(
|
||||||
|
"../static/cmap/view/cmap-item-decorator.js");
|
||||||
|
const listeners = new Map();
|
||||||
|
const linkedButton = {
|
||||||
|
dataset: {},
|
||||||
|
addEventListener(type, listener) { listeners.set(type, listener); }
|
||||||
|
};
|
||||||
|
const elementListeners = new Map();
|
||||||
|
const element = {
|
||||||
|
dataset: {},
|
||||||
|
style: {},
|
||||||
|
classList: { add() {}, remove() {}, toggle() {} },
|
||||||
|
setAttribute() {},
|
||||||
|
addEventListener(type, listener) { elementListeners.set(type, listener); },
|
||||||
|
querySelector(selector) {
|
||||||
|
return selector === ".rw-cmap-open-linked" ? linkedButton : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const openedMaps = [];
|
||||||
|
const editor = {
|
||||||
|
labels: { relation: "Relation" },
|
||||||
|
selectedItems: new Set(),
|
||||||
|
selectedItem: null,
|
||||||
|
activeMapRoot: null,
|
||||||
|
onOpenCmap: (record) => openedMaps.push(record),
|
||||||
|
ensureCanvasExtent() {}
|
||||||
|
};
|
||||||
|
const record = {
|
||||||
|
id: 1,
|
||||||
|
kind: "concept",
|
||||||
|
label: "Linked map",
|
||||||
|
cmapSlug: "other",
|
||||||
|
node: {
|
||||||
|
attr(name) {
|
||||||
|
return { x: 10, y: 20, width: 120, height: 50 }[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const decorator = new CmapItemDecorator(editor);
|
||||||
|
decorator.fitItemToContent = () => {};
|
||||||
|
|
||||||
|
decorator.decorateItem(record, element);
|
||||||
|
|
||||||
|
assert.equal(elementListeners.has("click"), false,
|
||||||
|
"the complete concept must not become a navigation target");
|
||||||
|
listeners.get("click")({ preventDefault() {}, stopPropagation() {} });
|
||||||
|
assert.deepEqual(openedMaps, [record]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapsed submap descendants stay hidden after the first render frame", async () => {
|
||||||
|
const factory = await loadEditorFactory();
|
||||||
|
const renderFrames = [];
|
||||||
|
global.window.requestAnimationFrame = (callback) => renderFrames.push(callback);
|
||||||
|
const map = {
|
||||||
|
onSelection() {},
|
||||||
|
onActivation() {},
|
||||||
|
node: (attributes) => fakeNode(attributes)
|
||||||
|
};
|
||||||
|
const canvas = {
|
||||||
|
addEventListener() {}, querySelector: () => null,
|
||||||
|
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
||||||
|
isConnected: true
|
||||||
|
};
|
||||||
|
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
|
const submap = editor.addItem({ id: 1, kind: "submap", label: "Collapsed" });
|
||||||
|
submap.expanded = false;
|
||||||
|
const child = editor.addItem({ id: 2, label: "Hidden child", parentSubmap: submap });
|
||||||
|
|
||||||
|
editor.applyCurrentContextLayout();
|
||||||
|
assert.equal(child.node.visible(), false);
|
||||||
|
|
||||||
|
child.node.visible(true);
|
||||||
|
for (const renderFrame of renderFrames.splice(0)) renderFrame();
|
||||||
|
|
||||||
|
assert.equal(child.node.visible(), false,
|
||||||
|
"the post-render visibility pass hides a descendant exposed by initial rendering");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loading an unresolved connector retains it without a runtime error", async () => {
|
||||||
|
const factory = await loadEditorFactory();
|
||||||
|
global.window.requestAnimationFrame = (callback) => callback();
|
||||||
|
const map = {
|
||||||
|
onSelection() {},
|
||||||
|
onActivation() {},
|
||||||
|
node: (attributes) => fakeNode(attributes)
|
||||||
|
};
|
||||||
|
const canvas = {
|
||||||
|
addEventListener() {}, querySelector: () => null,
|
||||||
|
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
|
||||||
|
isConnected: true
|
||||||
|
};
|
||||||
|
const editor = factory.createEditor(canvas, { createDiagramEngine: () => map });
|
||||||
|
const warnings = [];
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (...values) => warnings.push(values);
|
||||||
|
|
||||||
|
try {
|
||||||
|
editor.loadDocument({
|
||||||
|
schemaVersion: 2,
|
||||||
|
concepts: [{ id: "concept-one", label: "One" }],
|
||||||
|
items: [{
|
||||||
|
id: 1, conceptId: "concept-one", kind: "concept",
|
||||||
|
x: 20, y: 20, width: 120, height: 50
|
||||||
|
}],
|
||||||
|
connectors: [{
|
||||||
|
id: 1, sourceId: 1, targetId: 999,
|
||||||
|
hasArrow: true, lineColor: "#333", lineWidth: 2
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(editor.unresolvedConnectors.length, 1);
|
||||||
|
assert.match(warnings[0][0], /relation retained without rendering/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user