From 2c141b6d3f97335ac6e3b29566dbf8b66cdc0905 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Thu, 3 Sep 2026 08:40:15 +0200 Subject: [PATCH] big cmap refactoring --- static/cmap/README.md | 34 +- static/cmap/cmap-racket-wiki.js | 2016 ++------------ static/cmap/cmap-utils.js | 93 + static/cmap/cmap-view.js | 10 +- static/cmap/cmap.css | 49 +- static/cmap/cmap.js | 2310 +---------------- static/cmap/controller/cmap-history.js | 194 ++ .../controller/cmap-interaction-controller.js | 568 ++++ .../controller/cmap-selection-controller.js | 532 ++++ .../cmap/controller/cmap-submap-controller.js | 340 +++ static/cmap/engine/diagram-component.js | 116 + static/cmap/engine/diagram-engine.js | 184 ++ static/cmap/engine/diagram-group.js | 204 ++ static/cmap/engine/diagram-link.js | 113 + static/cmap/engine/diagram-node.js | 48 + static/cmap/engine/drawing-collections.js | 86 + static/cmap/engine/drawing-components.js | 524 ++++ static/cmap/engine/drawing-core.js | 9 + static/cmap/engine/drawing-relations.js | 308 +++ static/cmap/engine/drawing-support.js | 340 +++ static/cmap/engine/drawing-surface.js | 615 +++++ static/cmap/model/interchange.js | 90 +- static/cmap/view/cmap-item-decorator.js | 546 ++++ static/index.html | 62 +- static/js/wiki/cmap/workspace.js | 33 +- test/cmap-connector-rewire.test.js | 49 +- test/cmap-dirty-layout.test.js | 4 +- test/cmap-engine-groups.test.mjs | 98 + test/cmap-history.test.mjs | 48 + test/cmap-layering.test.js | 10 +- test/cmap-selection-layout.test.js | 5 +- test/cmap-submap-navigation.test.js | 194 +- 32 files changed, 5616 insertions(+), 4216 deletions(-) create mode 100644 static/cmap/cmap-utils.js create mode 100644 static/cmap/controller/cmap-history.js create mode 100644 static/cmap/controller/cmap-interaction-controller.js create mode 100644 static/cmap/controller/cmap-selection-controller.js create mode 100644 static/cmap/controller/cmap-submap-controller.js create mode 100644 static/cmap/engine/diagram-component.js create mode 100644 static/cmap/engine/diagram-engine.js create mode 100644 static/cmap/engine/diagram-group.js create mode 100644 static/cmap/engine/diagram-link.js create mode 100644 static/cmap/engine/diagram-node.js create mode 100644 static/cmap/engine/drawing-collections.js create mode 100644 static/cmap/engine/drawing-components.js create mode 100644 static/cmap/engine/drawing-core.js create mode 100644 static/cmap/engine/drawing-relations.js create mode 100644 static/cmap/engine/drawing-support.js create mode 100644 static/cmap/engine/drawing-surface.js create mode 100644 static/cmap/view/cmap-item-decorator.js create mode 100644 test/cmap-engine-groups.test.mjs create mode 100644 test/cmap-history.test.mjs diff --git a/static/cmap/README.md b/static/cmap/README.md index 282d3fe..0eb07de 100644 --- a/static/cmap/README.md +++ b/static/cmap/README.md @@ -2,12 +2,32 @@ This directory contains the complete browser-side CMap component. -`cmap.js` is the drawing and hit-testing engine. It is based on the -MIT-licensed ionstage/cmap 0.1.3 source and is now maintained as part of -racket-wiki. Its public additions include `onSelection` on a map and -`onActivation` and `onRendered` callbacks. Activation is recognized inside the -same hit-test and drag lifecycle as selection, so it does not depend on a DOM -`dblclick` event that may be suppressed by dragging. +`cmap.js` is the ES-module entry point of the diagram engine. Its public +`DiagramEngine`, `DiagramNode` and `DiagramLink` classes expose only the API +used by the wiki editor: creating nodes and links, changing their presentation, +connecting endpoints, zooming and receiving render, selection, activation and +move events. The engine does not install a `window.Cmap` global. + +The modules under `engine/` separate that API from the renderer internals. +`diagram-engine.js` owns the surface and component lifetime; +`diagram-node.js`, `diagram-link.js` and `diagram-component.js` define the +public handles. The `drawing-*` modules contain DOM rendering, geometry, +relations, hit testing and pointer interaction. That rendering core is based on +the MIT-licensed ionstage/cmap 0.1.3 source and is maintained as part of +racket-wiki. Activation is recognized inside the same hit-test and drag +lifecycle as selection, so it does not depend on a DOM `dblclick` event that +may be suppressed by dragging. + +`diagram-group.js` adds the generic view-level `DiagramGroup` abstraction. A +group contains nodes or nested groups, calculates a frame from their geometry, +and can be expanded or collapsed. It deliberately knows nothing about CMaps, +wiki pages or aspects. `DiagramEngine.setFilter` accepts an application policy +for component visibility; the engine combines that policy with each component's +own visibility and hides links whose endpoints are hidden. A wiki adapter can +therefore translate submap membership or aspect matching into groups and +filters without putting domain rules into the drawing engine. Proxy endpoints +and boundary navigation remain application concerns until a generic endpoint +projection API is introduced. `model/concept-repository.js` owns shared concepts, semantic concept relations and concept ownership. `model/concept-map.js` owns one map's concept @@ -25,7 +45,7 @@ The repository objects keep only an in-memory session copy; the backend remains authoritative. None of these model modules contains DOM or drawing-engine objects, and CMap state is not persisted in browser storage. -`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance. +`cmap-view.js` owns the canvas and its concrete `DiagramEngine` instance. `view/appearance-editor.js` presents the appearance model in the concept dialog and coordinates explicit style and palette changes with its repository. The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js index c26e1ae..c1195df 100644 --- a/static/cmap/cmap-racket-wiki.js +++ b/static/cmap/cmap-racket-wiki.js @@ -6,7 +6,23 @@ import { PLACEMENT_FIELDS } from "./model/concept-map.js"; import { CONCEPT_FIELDS } from "./model/concept-repository.js"; +import { CmapHistory } from "./controller/cmap-history.js"; +import { CmapSubmapController } from "./controller/cmap-submap-controller.js"; +import { CmapSelectionController } from "./controller/cmap-selection-controller.js"; +import { CmapInteractionController } from "./controller/cmap-interaction-controller.js"; +import { CmapItemDecorator } from "./view/cmap-item-decorator.js"; import { CmapView } from "./cmap-view.js"; +import { + debug, + debugPrefix, + elementDescription, + selectionStyle, + escapeHtml, + numberOr, + conceptDescriptionReference, + newConceptId, + normalizeConceptTags +} from "./cmap-utils.js"; /* * Racket Wiki editor layer for the bundled racket-wiki CMap component. @@ -17,120 +33,21 @@ import { CmapView } from "./cmap-view.js"; (() => { "use strict"; - const debugPrefix = "[racket-wiki:cmap 0.2.122]"; - // The editor is rebuilt when navigating between stored CMaps. Clipboard - // entries therefore belong to the shared module, not to one editor instance. - let copiedConceptReferences = []; - - function debug(message, details) { - if (details === undefined) { - console.info(debugPrefix, message); - return; - } - console.info(debugPrefix, message, details); - } - - function elementDescription(element) { - if (!(element instanceof Element)) return String(element); - return { - tag: element.tagName, - id: element.id || null, - classes: Array.from(element.classList), - itemId: element.dataset.rwCmapItemId || null - }; - } - - function selectionStyle(element) { - if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null; - const style = window.getComputedStyle(element); - return { - pointerEvents: style.pointerEvents, - outline: style.outline, - outlineOffset: style.outlineOffset, - boxShadow: style.boxShadow, - overflow: style.overflow, - zIndex: style.zIndex - }; - } - debug("cmap-racket-wiki.js loaded", { script: document.currentScript ? document.currentScript.src : null, - cmapAvailable: typeof window.Cmap === "function", + cmapAvailable: true, stylesheets: Array.from(document.styleSheets || []) .map((sheet) => sheet.href) .filter((href) => href && href.includes("cmap.css")) }); - ////////////////////////////////////////////////////////////////////////////// - // Small helpers - ////////////////////////////////////////////////////////////////////////////// - - function escapeHtml(value) { - return String(value || "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - } - - function numberOr(value, fallback) { - return Number.isFinite(value) ? value : fallback; - } - - function conceptDescriptionReference(label, id) { - const slug = String(label || "") - .normalize("NFKD") - .toLocaleLowerCase() - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^\p{L}\p{N}]+/gu, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 120) - .replace(/-+$/g, ""); - return `cmap:${slug || `concept-${id}`}`; - } - - function newConceptId() { - if (window.crypto && typeof window.crypto.randomUUID === "function") { - try { - return 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("")}`; - } - - 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); - } - ////////////////////////////////////////////////////////////////////////////// // Editor ////////////////////////////////////////////////////////////////////////////// /** * goal : Add the wiki editor model to the bundled CMap drawing component. - * pre : canvas is a DOM element and window.Cmap is available. + * pre : canvas is a DOM element. * post : Concepts and linking phrases can be selected and connected by * direct manipulation. * result : A CmapEditor instance. @@ -138,7 +55,6 @@ import { CmapView } from "./cmap-view.js"; class CmapEditor { constructor(canvas, options = {}) { this.canvas = canvas; - this.CmapFactory = options.Cmap || window.Cmap; this.renderItem = options.renderItem || null; this.onOpenPage = options.onOpenPage || null; this.onOpenSubMap = options.onOpenSubMap || null; @@ -146,6 +62,7 @@ import { CmapView } from "./cmap-view.js"; this.onSubMapPromoted = options.onSubMapPromoted || null; this.onMapChange = options.onMapChange || null; this.onOpenCmap = options.onOpenCmap || null; + this.onOpenParentCmap = options.onOpenParentCmap || null; this.onOpenExternalUrl = options.onOpenExternalUrl || null; this.onOpenStoredSubMap = options.onOpenStoredSubMap || null; this.onOpenBoundaryReference = options.onOpenBoundaryReference || null; @@ -164,9 +81,9 @@ import { CmapView } from "./cmap-view.js"; }; this.view = new CmapView( canvas, - this.CmapFactory, - (component, event) => this.handleMapSelection(component, event), - (component, event) => this.handleMapActivation(component, event)); + (component, event) => this.interaction.handleMapSelection(component, event), + (component, event) => this.interaction.handleMapActivation(component, event), + options.createDiagramEngine || null); this.map = this.view.map; this.items = []; this.connectors = []; @@ -187,20 +104,27 @@ import { CmapView } from "./cmap-view.js"; this.zoomFactor = 1; this.marqueeMouseDownHandler = null; this.activeMarqueeCleanup = null; - this.undoStack = []; - this.redoStack = []; - this.historySnapshotValue = null; - this.historyTimer = null; - this.historyReady = false; - this.historyRestoring = false; - this.historyLimit = 100; + this.canvasPanPointerDownHandler = null; + this.activeCanvasPanCleanup = null; + this.history = new CmapHistory({ + snapshot: () => this.historySnapshot(), + restore: (snapshot) => this.restoreHistoryDocument(snapshot), + onChange: (change) => { + if (this.onHistoryChange) this.onHistoryChange(change); + } + }); + this.submaps = new CmapSubmapController(this); + this.selection = new CmapSelectionController(this); + this.interaction = new CmapInteractionController(this); + this.decorator = new CmapItemDecorator(this); this.boundaryLayer = null; this.boundaryScrollHandler = () => this.refreshBoundaryReferences(); this.canvas.addEventListener("scroll", this.boundaryScrollHandler, { passive: true }); - this.installMarqueeSelection(); + this.interaction.installCanvasPanning(); + this.interaction.installMarqueeSelection(); debug("editor created", { canvas: elementDescription(canvas), - cmapFactoryAvailable: typeof this.CmapFactory === "function" + drawingEngine: this.map.constructor.name }); } @@ -431,72 +355,34 @@ import { CmapView } from "./cmap-view.js"; return JSON.stringify(this.toDocument()); } - notifyHistory() { - if (this.onHistoryChange) { - this.onHistoryChange({ - canUndo: this.canUndo(), - canRedo: this.canRedo() - }); - } - } - resetHistory() { - if (this.historyTimer !== null) { - window.clearTimeout(this.historyTimer); - this.historyTimer = null; - } - this.undoStack = []; - this.redoStack = []; - this.historyReady = true; - this.historySnapshotValue = this.historySnapshot(); - this.notifyHistory(); + this.history.reset(); } scheduleHistoryCommit() { - if (!this.historyReady || this.historyRestoring) return; - if (this.historyTimer !== null) window.clearTimeout(this.historyTimer); - this.historyTimer = window.setTimeout(() => { - this.historyTimer = null; - this.commitHistory(); - }, 0); + this.history.scheduleCommit(); } refreshHistorySnapshot() { - if (!this.historyReady || this.historyRestoring || this.historyTimer !== null) return; - this.historySnapshotValue = this.historySnapshot(); + this.history.refreshSnapshot(); } commitHistory() { - if (!this.historyReady || this.historyRestoring) return false; - if (this.historyTimer !== null) { - window.clearTimeout(this.historyTimer); - this.historyTimer = null; - } - const nextSnapshot = this.historySnapshot(); - if (nextSnapshot === this.historySnapshotValue) return false; - if (this.historySnapshotValue !== null) { - this.undoStack.push(this.historySnapshotValue); - if (this.undoStack.length > this.historyLimit) this.undoStack.shift(); - } - this.historySnapshotValue = nextSnapshot; - this.redoStack = []; - this.notifyHistory(); - return true; + return this.history.commit(); } canUndo() { - return this.undoStack.length > 0; + return this.history.canUndo(); } canRedo() { - return this.redoStack.length > 0; + return this.history.canRedo(); } clearDocument() { this.clearSelection(false); for (const connector of this.connectors) connector.link.remove(); for (const record of this.items) { - if (record.submapFrameElement) record.submapFrameElement.remove(); if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove(); record.node.remove(); } @@ -517,50 +403,37 @@ import { CmapView } from "./cmap-view.js"; this.nextGroupId = 1; } - restoreHistorySnapshot(snapshot) { + restoreHistoryDocument(snapshot) { const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null; const mapHistoryIds = this.mapHistory.map((record) => record.id); - this.historyRestoring = true; - try { - this.clearDocument(); - this.loadDocument(JSON.parse(snapshot)); - this.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null; - this.mapHistory = mapHistoryIds - .map((id) => this.items.find((item) => item.id === id)) - .filter(Boolean); - this.applyCurrentContextLayout(); - const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; - if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); - } finally { - this.historyRestoring = false; - } - this.historySnapshotValue = snapshot; + this.clearDocument(); + this.loadDocument(JSON.parse(snapshot)); + this.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null; + this.mapHistory = mapHistoryIds + .map((id) => this.items.find((item) => item.id === id)) + .filter(Boolean); + this.applyCurrentContextLayout(); + const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; + if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); this.notifySelection(); - this.notifyHistory(); } undo() { - this.commitHistory(); - if (!this.canUndo()) return false; - this.redoStack.push(this.historySnapshotValue); - const snapshot = this.undoStack.pop(); - this.restoreHistorySnapshot(snapshot); + const restored = this.history.undo(); + if (!restored) return false; debug("undo applied", { - undoCount: this.undoStack.length, - redoCount: this.redoStack.length + undoCount: this.history.undoCount, + redoCount: this.history.redoCount }); return true; } redo() { - this.commitHistory(); - if (!this.canRedo()) return false; - this.undoStack.push(this.historySnapshotValue); - const snapshot = this.redoStack.pop(); - this.restoreHistorySnapshot(snapshot); + const restored = this.history.redo(); + if (!restored) return false; debug("redo applied", { - undoCount: this.undoStack.length, - redoCount: this.redoStack.length + undoCount: this.history.undoCount, + redoCount: this.history.redoCount }); return true; } @@ -630,7 +503,6 @@ import { CmapView } from "./cmap-view.js"; synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle || options.synopsisFontStyle || options.fontStyle || "normal" }])) : {}, - submapFrameElement: null, submapAnchorLineElement: null, imageSource: options.imageSource || "", backgroundColor: options.backgroundColor || "#f3f6f8", @@ -727,7 +599,7 @@ import { CmapView } from "./cmap-view.js"; } mapContextKey(root = this.activeMapRoot) { - return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root"; + return this.submaps.mapContextKey(root); } itemLayout(record) { @@ -810,7 +682,13 @@ import { CmapView } from "./cmap-view.js"; this.refreshSubmapVisibility(); this.refreshConnectorGeometry(); window.requestAnimationFrame(() => { - if (!this.destroyed && this.canvas.isConnected) this.refreshConnectorGeometry(); + if (!this.destroyed && this.canvas.isConnected) { + // DrawingSurface creates and attaches its DOM surface asynchronously. + // Reapply context visibility after that first render so descendants + // of collapsed submaps cannot briefly become the rendered baseline. + this.refreshSubmapVisibility(); + this.refreshConnectorGeometry(); + } }); } @@ -832,8 +710,7 @@ import { CmapView } from "./cmap-view.js"; } itemInsideActiveMap(record) { - return Boolean(this.activeMapRoot && - (record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot))); + return this.submaps.itemInsideActiveMap(record); } boundaryConceptFor(record, crossedConnector, inside) { @@ -948,208 +825,68 @@ import { CmapView } from "./cmap-view.js"; } isDescendantOf(record, submap) { - let parent = record.parentSubmap; - while (parent) { - if (parent === submap) return true; - parent = parent.parentSubmap; - } - return false; + return this.submaps.isDescendantOf(record, submap); } 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 this.submaps.isItemVisible(record); } 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 this.submaps.hiddenItemsInCurrentContext(); } canHideSelectionInCurrentContext() { - return !this.activeMapRoot && this.selectedAll().some((item) => item.parentSubmap && - item.kind !== "phrase" && this.isItemVisible(item)); + return this.submaps.canHideSelectionInCurrentContext(); } hideSelectionInCurrentContext() { - const context = this.mapContextKey(); - if (this.activeMapRoot) return false; - const selected = this.selectedAll().filter((item) => item.parentSubmap && - item.kind !== "phrase" && this.isItemVisible(item)); - if (!selected.length) return false; - this.scheduleHistoryCommit(); - for (const item of selected) item.hiddenContexts.add(context); - this.clearSelection(); - this.refreshSubmapVisibility(); - if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext()); - return true; + return this.submaps.hideSelectionInCurrentContext(); } showItemInCurrentContext(record) { - if (!record) return false; - const context = this.mapContextKey(); - if (!record.hiddenContexts.has(context)) return false; - this.scheduleHistoryCommit(); - record.hiddenContexts.delete(context); - this.refreshSubmapVisibility(); - if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext()); - return true; + return this.submaps.showItemInCurrentContext(record); } ensureSubmapContents(record) { - if (record.submapInitialized) return; - record.submapInitialized = true; - if (this.onPopulateSubMap) this.onPopulateSubMap(record, this); + return this.submaps.ensureSubmapContents(record); } toggleSubmap(record, expanded = !record.expanded) { - if (!record || record.kind !== "submap") return false; - // Legacy embedded pages had only an "open" state. New shared sub-CMaps - // keep their expand/collapse state in the parent and use a separate - // button for the standalone route. - if (record.separateMap && !record.cmapSlug) { - record.expanded = false; - if (record === this.activeMapRoot) { - this.refreshSubmapVisibility(); - return false; - } - return this.openSubmapMap(record); - } - // Persist the complete placement before hiding descendants. Reapplying - // the same document-local context on expansion guarantees that a - // submap opens at the same coordinates and dimensions every time. - this.saveCurrentContextLayout(); - if (expanded) this.ensureSubmapContents(record); - record.expanded = Boolean(expanded); - if (record.expanded) this.applyCurrentContextLayout(); - else this.refreshSubmapVisibility(); - const element = record.node.element(); - if (element) this.ensureSubmapToggle(record, element); - debug("submap toggled", { - id: record.id, - expanded: record.expanded, - childCount: this.items.filter((item) => item.parentSubmap === record).length - }); - if (this.onOpenSubMap) this.onOpenSubMap(record, record.expanded); - this.scheduleHistoryCommit(); - return record.expanded; + return this.submaps.toggleSubmap(record, expanded); } openSubmapMap(record) { - if (!record || record.kind !== "submap" || !record.separateMap) return false; - if (record === this.activeMapRoot) return true; - this.ensureSubmapContents(record); - this.clearSelection(); - this.saveCurrentContextLayout(); - if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot); - this.activeMapRoot = record; - this.applyCurrentContextLayout(); - if (this.onMapChange) this.onMapChange(record.mapReference, record); - debug("separate concept map opened", { id: record.id, mapReference: record.mapReference }); - return true; + return this.submaps.openSubmapMap(record); } openRootMap() { - if (!this.activeMapRoot) return false; - this.clearSelection(); - this.saveCurrentContextLayout(); - this.activeMapRoot = null; - this.mapHistory = []; - this.applyCurrentContextLayout(); - if (this.onMapChange) this.onMapChange(null, null); - debug("root concept map opened"); - return true; + return this.submaps.openRootMap(); } canStepBackWithinMap() { - return this.mapHistory.length > 0; + return this.submaps.canStepBackWithinMap(); } openParentMap() { - if (!this.activeMapRoot) return false; - this.clearSelection(); - this.saveCurrentContextLayout(); - this.activeMapRoot = this.mapHistory.pop() || null; - this.applyCurrentContextLayout(); - const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; - if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); - debug("parent concept map opened", { - id: this.activeMapRoot ? this.activeMapRoot.id : null, - mapReference: reference - }); - return true; + return this.submaps.openParentMap(); } promoteSubmap(record, name) { - if (!record || record.kind !== "submap") return null; - this.ensureSubmapContents(record); - record.childMap = String(name || record.label).trim() || record.label; - record.separateMap = true; - record.mapReference = { - id: `cmap-${record.id}`, - title: record.childMap, - rootItemId: record.id, - itemIds: this.items - .filter((item) => this.isDescendantOf(item, record)) - .map((item) => item.id) - }; - this.conceptMaps.set(record.mapReference.id, record.mapReference); - record.expanded = false; - this.updateItem(record, { - synopsis: `Concept map: ${record.childMap}` - }); - if (this.onSubMapPromoted) this.onSubMapPromoted(record); - this.refreshSubmapVisibility(); - debug("submap promoted to separate map", { id: record.id, childMap: record.childMap }); - return record.mapReference; + return this.submaps.promoteSubmap(record, name); } prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) { - if (!record || record.kind !== "submap") return null; - this.ensureSubmapContents(record); - return this.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata); + return this.submaps.prepareStoredSubmapExtraction(record, targetSlug, childMetadata); } replaceDocument(document) { if (!document || typeof document !== "object") return false; - this.commitHistory(); - const previousSnapshot = this.historySnapshotValue || this.historySnapshot(); - this.historyRestoring = true; - try { + this.history.replace(() => { this.clearDocument(); this.loadDocument(document); - } finally { - this.historyRestoring = false; - } - const nextSnapshot = this.historySnapshot(); - if (previousSnapshot !== nextSnapshot) { - this.undoStack.push(previousSnapshot); - if (this.undoStack.length > this.historyLimit) this.undoStack.shift(); - } - this.historySnapshotValue = nextSnapshot; - this.redoStack = []; + }); this.notifySelection(); - this.notifyHistory(); return true; } @@ -1172,6 +909,26 @@ import { CmapView } from "./cmap-view.js"; return Math.round(this.zoomFactor * 100); } + /** + * Install a record-level visibility filter on the generic diagram engine. + * + * The callback receives a wiki item or connector record and its public + * diagram handle. It may return a boolean or a `{ visible }` decision. + * The editor's own visibility rules remain the base visibility and are + * combined with the supplied policy by DiagramEngine. + */ + setFilter(filter) { + if (filter !== null && filter !== undefined && typeof filter !== "function") { + throw new TypeError("A CMap filter must be a function"); + } + this.view.map.setFilter(filter ? (handle) => { + const item = this.items.find((record) => record.node === handle) || null; + const connector = this.connectors.find((record) => record.link === handle) || null; + return filter(item || connector, handle); + } : null); + return this; + } + surfaceElement() { return this.view.surfaceElement(); } @@ -1232,6 +989,7 @@ import { CmapView } from "./cmap-view.js"; textColor: record.textColor }); record.node.redraw(); + if (record.kind === "submap") this.submaps.updateGroupAppearance(record); this.redrawConnectorsFor(record); this.refreshSubmapVisibility(); } @@ -1392,458 +1150,29 @@ import { CmapView } from "./cmap-view.js"; return true; } - /** - * goal : Select a concept/linking phrase, optionally beside the current selection. - * pre : record belongs to this editor. - * post : Its logical group is selected as one unit; the primary item exposes handles. - */ - selectItem(record, options = {}) { - if (!record) { - this.clearSelection(); - return; - } - const additive = Boolean(options.additive); - const toggle = Boolean(options.toggle); - const groupRecords = record.groupId && options.expandGroup !== false ? - this.items.filter((item) => item.groupId === record.groupId && this.isItemVisible(item)) : - [record]; - debug("selectItem called", { - requestedId: record.id, - requestedKind: record.kind, - additive, - groupId: record.groupId, - previousIds: this.selectedAll().map((item) => item.id) - }); - - if (!additive) this.clearSelection(false); - const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item)); - for (const item of groupRecords) { - if (remove) { - this.selectedItems.delete(item); - } else { - this.selectedItems.add(item); - } - } - - this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record; - this.selectedConnector = null; - this.refreshSelectionDecoration(); - debug("selection applied", { - selectedId: this.selectedItem ? this.selectedItem.id : null, - selectedIds: this.selectedAll().map((item) => item.id), - selectionCount: this.selectedItems.size - }); - this.notifySelection(); - } - - refreshSelectionDecoration() { - for (const item of this.items) { - const element = item.node.element(); - const selected = this.selectedItems.has(item); - if (element) { - element.classList.toggle("rw-cmap-selected", selected); - 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.removeHandles(element); - if (selected && item === this.selectedItem) this.ensureHandles(item, element); - } - if (item.kind === "submap") this.updateSubmapFrame(item); - } - } - - /** - * goal : Select a connector so its line becomes clearly visible. - * pre : record belongs to this editor. - * post : Previous selection is cleared and the connector is highlighted. - */ - selectConnector(record) { - this.clearSelection(); - this.selectedConnector = record; - record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 }); - record.link.redraw(); - this.notifySelection(); - } - - /** - * goal : Remove the current item/connector selection. - * post : No item handles or connector highlight remain. - */ - clearSelection(notify = true) { - const clearedItemIds = this.selectedAll().map((item) => item.id); - const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null; - for (const item of this.selectedItems) { - if (item.submapFrameElement) { - item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected"); - item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected-primary"); - } - 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.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.activeMapRoot && item.conceptId && item.kind !== "phrase"); - } - - cutSelectionReferences() { - const cuttable = this.selectedAll().filter((item) => - item !== this.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.activeMapRoot || null; - const pasted = sources.map((source, index) => this.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.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.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.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.saveCurrentContextLayout(); - this.refreshConnectorGeometry(); - for (const submap of this.items - .filter((item) => item.kind === "submap") - .sort((a, b) => b.submapDepth - a.submapDepth)) { - this.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.addItem({ - ...options, - kind: "submap", - label, - childMap: options.childMap || label, - synopsis: options.synopsis || "Grouped sub-concept map.", - parentSubmap, - submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, - x: numberOr(Number(options.x), left), - y: numberOr(Number(options.y), Math.max(20, top - 105)), - backgroundColor: options.backgroundColor || "#edf7e8", - borderColor: options.borderColor || "#57834a" - }); - - submap.expanded = true; - submap.submapInitialized = true; - for (const item of selected) { - item.groupId = null; - item.parentSubmap = submap; - this.updateSubmapDepth(item, submap.submapDepth + 1); - } - this.reconcilePhraseMembership(); - this.refreshConceptMapReferences(); - this.applyCurrentContextLayout(); - this.selectItem(submap); - debug("selection grouped as submap", { - submapId: submap.id, - itemIds: selected.map((item) => item.id) - }); - return submap; - } - - canUngroupSelection() { - return this.selectedAll().some((item) => - Boolean(item.groupId) || - (item.kind === "submap" && !item.separateMap) || - Boolean(item.parentSubmap && item.parentSubmap !== this.activeMapRoot)); - } - - ungroupSelection() { - const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean)); - const affected = this.items.filter((item) => groupIds.has(item.groupId)); - for (const item of affected) item.groupId = null; - - const selected = this.selectedAll(); - const selectedSubmaps = new Set(selected.filter((item) => - item.kind === "submap" && !item.separateMap)); - const liftedChildren = new Set(); - for (const submap of selectedSubmaps) { - const parent = submap.parentSubmap; - const children = this.items.filter((item) => item.parentSubmap === submap); - for (const child of children) { - liftedChildren.add(child); - child.parentSubmap = parent; - this.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0); - } - if (submap.submapFrameElement) { - submap.submapFrameElement.remove(); - submap.submapFrameElement = null; - } - submap.expanded = false; - submap.submapInitialized = false; - submap.childMap = null; - this.updateItem(submap, { kind: "concept" }); - affected.push(submap, ...children); - } - - for (const item of selected) { - if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap || - item.parentSubmap === this.activeMapRoot) continue; - const parent = item.parentSubmap.parentSubmap; - item.parentSubmap = parent; - this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0); - affected.push(item); - } - - if (!affected.length) return false; - this.reconcilePhraseMembership(); - this.refreshConceptMapReferences(); - this.refreshSubmapVisibility(); - this.refreshSelectionDecoration(); - debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) }); - this.notifySelection(); - this.scheduleHistoryCommit(); - return true; - } + // Selection Delegation + selectItem(record, options) { return this.selection.selectItem(record, options); } + selectConnector(record) { return this.selection.selectConnector(record); } + clearSelection(notify) { return this.selection.clearSelection(notify); } + selected() { return this.selection.selected(); } + selectedAll() { return this.selection.selectedAll(); } + refreshSelectionDecoration() { return this.selection.refreshSelectionDecoration(); } + storeConceptReferences(records) { return this.selection.storeConceptReferences(records); } + copySelectionReferences() { return this.selection.copySelectionReferences(); } + canCutSelectionReferences() { return this.selection.canCutSelectionReferences(); } + cutSelectionReferences() { return this.selection.cutSelectionReferences(); } + canPasteConceptReferences() { return this.selection.canPasteConceptReferences(); } + pasteConceptReferences() { return this.selection.pasteConceptReferences(); } + selectAll() { return this.selection.selectAll(); } + layoutSelectionRecords() { return this.selection.layoutSelectionRecords(); } + canLayoutSelection(command) { return this.selection.canLayoutSelection(command); } + applySelectionLayout(command) { return this.selection.applySelectionLayout(command); } + canGroupSelection() { return this.selection.canGroupSelection(); } + groupSelection(options) { return this.selection.groupSelection(options); } + canUngroupSelection() { return this.selection.canUngroupSelection(); } + ungroupSelection() { return this.selection.ungroupSelection(); } + deleteSelection() { return this.selection.deleteSelection(); } + notifySelection() { return this.selection.notifySelection(); } getDocumentMetadata() { return { @@ -1948,15 +1277,27 @@ import { CmapView } from "./cmap-view.js"; this.refreshConceptMapReferences(); this.applyCurrentContextLayout(); this.clearSelection(); - if (!this.historyRestoring) this.resetHistory(); + if (!this.history.isRestoring) this.resetHistory(); return this; } - /** - * goal : Start editing the currently selected item. - * post : A phrase is edited inline; another item uses the host editor. - * result : True when an item was available for editing. - */ + // Interaction Delegation + startRelationDrag(event, source) { return this.interaction.startRelationDrag(event, source); } + finishRelation(source, target, direct) { return this.interaction.finishRelation(source, target, direct); } + createDraftLine(start) { return this.interaction.createDraftLine(start); } + startResize(event, record) { return this.interaction.startResize(event, record); } + handleItemMove(record, x, y) { return this.interaction.handleItemMove(record, x, y); } + beginItemMove(record, includeDescendants) { return this.interaction.beginItemMove(record, includeDescendants); } + moveSubmapGroup(record, x, y, moveMembership) { return this.interaction.moveSubmapGroup(record, x, y, moveMembership); } + handleItemMoveEnd(record) { return this.interaction.handleItemMoveEnd(record); } + startSubmapFrameDrag(event, record) { return this.interaction.startSubmapFrameDrag(event, record); } + installMarqueeSelection() { return this.interaction.installMarqueeSelection(); } + installCanvasPanning() { return this.interaction.installCanvasPanning(); } + handleMapSelection(component, event) { return this.interaction.handleMapSelection(component, event); } + handleMapActivation(component, event) { return this.interaction.handleMapActivation(component, event); } + + // Decorator Delegation + itemHtml(record) { return this.decorator.itemHtml(record); } editSelected() { const record = this.selectedItem; if (!record) return false; @@ -1967,463 +1308,17 @@ import { CmapView } from "./cmap-view.js"; } return true; } - - /** - * goal : Start direct editing of a linking phrase. - * pre : record.kind is "phrase". - * post : An input appears in the relation-name node and receives focus. - */ - editPhraseInline(record) { - if (!record || record.kind !== "phrase") return; - const value = record.label || "?????"; - record.node.attr("content", - ``); - record.node.redraw(); - const element = record.node.element(); - const input = element ? element.querySelector(".rw-cmap-phrase-input") : null; - if (!input) { - record.editWhenRendered = true; - return; - } - - const commit = () => { - // Redrawing a phrase can synchronously fit it to its new label. Keep - // that layout work inside this edit transaction without losing the - // snapshot from before the text change. - this.scheduleHistoryCommit(); - const text = input.value.trim() || "?????"; - record.label = text; - record.node.attr("content", this.itemHtml(record)); - record.node.redraw(); - this.selectItem(record); - }; - - input.addEventListener("pointerdown", (event) => event.stopPropagation()); - input.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - input.blur(); - } - if (event.key === "Escape") { - event.preventDefault(); - input.value = value; - input.blur(); - } - }); - input.addEventListener("blur", commit, { once: true }); - input.focus(); - input.select(); - } - - /** - * Apply the independently stored heading and synopsis typography. - * Legacy records have already inherited their old shared typography in - * addItem, so merely opening an existing CMap does not restyle it. - */ - applyItemTypography(record, element) { - const title = element.querySelector(".cmap-card-title"); - if (title) { - title.style.color = record.textColor; - title.style.fontFamily = record.fontFamily; - title.style.fontSize = record.fontSize; - title.style.fontWeight = record.fontWeight; - title.style.fontStyle = record.fontStyle; - } - const synopsis = element.querySelector(".cmap-card-synopsis"); - if (synopsis) { - synopsis.style.color = record.synopsisTextColor; - synopsis.style.fontFamily = record.synopsisFontFamily; - synopsis.style.fontSize = record.synopsisFontSize; - synopsis.style.fontWeight = record.synopsisFontWeight; - synopsis.style.fontStyle = record.synopsisFontStyle; - } - } - - /** - * goal : Redraw selection controls after ionstage/cmap updates a node. - * pre : record.node.redraw() has made a DOM element available. - * post : Selection, drag-to-link and resize interactions are attached. - */ - decorateItem(record, renderedElement = null) { - const element = renderedElement || record.node.element(); - if (!element) return; - element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase"); - element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`); - element.dataset.rwCmapItemId = String(record.id); - element.style.fontFamily = record.fontFamily; - element.style.fontSize = record.fontSize; - element.style.fontWeight = record.fontWeight; - element.style.fontStyle = record.fontStyle; - element.style.overflow = "visible"; - this.applyItemTypography(record, element); - - // Concept content is never presentation-optional. A context may retain a - // different width or a larger manual height, but it may not keep a - // height that clips part of the shared content. - 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.onOpenPage) { - this.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug }); - } - }); - } - - const linkedButton = element.querySelector(".rw-cmap-open-linked"); - if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") { - linkedButton.dataset.rwCmapBound = "1"; - linkedButton.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - linkedButton.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - if (record.parentCmapLink) { - this.openParentMap(); - } else if (record.cmapSlug && this.onOpenCmap) { - this.onOpenCmap(record); - } else if (record.pageSlug && this.onOpenPage) { - this.onOpenPage(record); - } - }); - } - - 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.onOpenExternalUrl) this.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.selectedItems.has(record)) { - element.classList.add("rw-cmap-selected"); - element.classList.toggle("rw-cmap-selected-primary", this.selectedItem === record); - element.setAttribute("aria-selected", "true"); - if (this.selectedItem === record) this.ensureHandles(record, element); - } - this.ensureSubmapToggle(record, element); - debug("cmap node rendered and decorated", { - id: record.id, - kind: record.kind, - selected: this.selectedItems.has(record), - element: elementDescription(element), - style: selectionStyle(element) - }); - if (record.editWhenRendered) { - record.editWhenRendered = false; - queueMicrotask(() => this.editPhraseInline(record)); - } - this.ensureCanvasExtent( - Number(record.node.attr("x")) + Number(record.node.attr("width")), - Number(record.node.attr("y")) + Number(record.node.attr("height")) - ); - if (!record.moveMembership) { - let parent = record.parentSubmap; - while (parent) { - this.updateSubmapFrame(parent); - parent = parent.parentSubmap; - } - } - } - - /** - * goal : Size automatic items and prevent fixed concept cards clipping content. - * pre : element has been rendered and contains the current item HTML. - * post : Automatic dimensions closely surround the text, with long text - * wrapping at a practical maximum width. - */ - fitItemToContent(record, element) { - record.fitContentPending = false; - if (record.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; - // Rendering may finish after the host has recorded its saved baseline. - // Give the host both exact states so it can advance that baseline only - // when no user edit occurred in between. - const beforeAutomaticLayout = this.onAutomaticLayoutChange ? this.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.redrawConnectorsFor(record); - debug("automatic item size applied", { - id: record.id, - kind: record.kind, - width: nextWidth, - height: nextHeight - }); - this.refreshHistorySnapshot(); - if (this.onAutomaticLayoutChange) { - const afterAutomaticLayout = this.historySnapshot(); - if (beforeAutomaticLayout !== afterAutomaticLayout) { - this.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); - } - - handleItemMove(record, x, y) { - // A submap node is an anchor placement. Moving the anchor never moves - // the separately positioned expanded group; the frame has its own drag. - const movesCompleteSubmap = false; - const movesSelection = this.selectedItems.has(record) && this.selectedItems.size > 1; - const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection); - if (movesCompleteSubmap || movesSelection) { - this.moveSubmapGroup(record, x, y, moveMembership); - } - return { x, y }; - } - - beginItemMove(record, includeDescendants = false) { - if (record.moveMembership) return record.moveMembership; - let groupItems = [record]; - if (includeDescendants) { - const selected = this.selectedItems.has(record) && this.selectedItems.size > 1 ? - this.selectedAll() : [record]; - const expanded = []; - for (const item of selected) { - expanded.push(item); - if (item.kind === "submap" && item !== this.activeMapRoot) { - expanded.push(...this.items.filter((candidate) => this.isDescendantOf(candidate, item))); - } - } - groupItems = Array.from(new Set(expanded)); - } - record.moveMembership = { - parent: record.parentSubmap, - parentBounds: record.parentSubmap ? this.submapBounds(record.parentSubmap) : null, - startX: Number(record.node.attr("x")), - startY: Number(record.node.attr("y")), - groupPositions: groupItems.map((item) => ({ - item, - x: Number(item.node.attr("x")), - y: Number(item.node.attr("y")) - })) - }; - return record.moveMembership; - } - - moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) { - const deltaX = x - moveMembership.startX; - const deltaY = y - moveMembership.startY; - for (const position of moveMembership.groupPositions) { - position.item.node.attr({ - x: position.x + deltaX, - y: position.y + deltaY - }); - position.item.node.redraw(); - } - for (const connector of this.connectors) { - connector.link.straighten(); - connector.link.redraw(); - } - for (const submap of this.items - .filter((item) => item.kind === "submap") - .sort((a, b) => b.submapDepth - a.submapDepth)) { - this.updateSubmapFrame(submap); - } - } - - handleItemMoveEnd(record) { - const moveMembership = record.moveMembership; - record.moveMembership = null; - if (!moveMembership) return; - - const movedItems = this.selectedAll().filter((item) => - moveMembership.groupPositions.some((position) => position.item === item)); - const movedParents = new Set(movedItems.map((item) => item.parentSubmap)); - if (movedItems.length > 1 && movedParents.size === 1) { - const previousParent = movedItems[0].parentSubmap; - const centers = movedItems.map((item) => this.itemCenter(item)); - const center = { - x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length, - y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length - }; - let parent = null; - if (previousParent && this.pointInBounds(center, moveMembership.parentBounds)) { - parent = previousParent; - } else { - parent = this.submapAtPoint(center, null, movedItems); - } - if (!parent && this.activeMapRoot && !movedItems.includes(this.activeMapRoot)) { - parent = this.activeMapRoot; - } - if (previousParent && parent !== previousParent && this.onConfirmDetachFromSubmap && - !this.onConfirmDetachFromSubmap(record, previousParent, parent)) { - parent = previousParent; - } - if (parent !== previousParent) { - for (const item of movedItems) { - if (item === this.activeMapRoot) continue; - item.parentSubmap = parent; - this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0); - } - this.reconcilePhraseMembership(); - this.refreshConceptMapReferences(); - debug("selection submap membership changed", { - itemIds: movedItems.map((item) => item.id), - previousParentId: previousParent ? previousParent.id : null, - parentId: parent ? parent.id : null - }); - } - this.refreshSubmapVisibility(); - this.scheduleHistoryCommit(); - return; - } - - if (record.kind === "phrase") { - this.scheduleHistoryCommit(); - return; - } - - // A separately opened map keeps its head linked to the parent map. Moving - // that head edits its position inside the current view; it must not be - // interpreted as dragging the complete map out of its parent submap. - if (record === this.activeMapRoot) { - this.refreshSubmapVisibility(); - debug("active map head moved without changing parent membership", { - id: record.id, - parentId: record.parentSubmap ? record.parentSubmap.id : null - }); - this.scheduleHistoryCommit(); - return; - } - - const center = this.itemCenter(record); - let parent = null; - if (moveMembership.parent && this.pointInBounds(center, moveMembership.parentBounds)) { - parent = moveMembership.parent; - } else { - parent = this.submapAtPoint(center, record); - } - if (!parent && this.activeMapRoot && record !== this.activeMapRoot) parent = this.activeMapRoot; - - if (moveMembership.parent && parent !== moveMembership.parent && - this.onConfirmDetachFromSubmap && - !this.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) { - parent = moveMembership.parent; - } - - if (parent !== record.parentSubmap) { - const previousParent = record.parentSubmap; - record.parentSubmap = parent; - this.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0); - debug("item submap membership changed", { - id: record.id, - previousParentId: previousParent ? previousParent.id : null, - parentId: parent ? parent.id : null - }); - this.reconcilePhraseMembership(); - this.refreshConceptMapReferences(); - } - this.refreshSubmapVisibility(); - this.scheduleHistoryCommit(); - } + editPhraseInline(record) { return this.decorator.editPhraseInline(record); } + applyItemTypography(record, element) { return this.decorator.applyItemTypography(record, element); } + decorateItem(record, renderedElement) { return this.decorator.decorateItem(record, renderedElement); } + fitItemToContent(record, element) { return this.decorator.fitItemToContent(record, element); } + decorateConnector(record, renderedElement) { return this.decorator.decorateConnector(record, renderedElement); } + ensureSubmapToggle(record, element) { return this.decorator.ensureSubmapToggle(record, element); } + ensureHandles(record, element) { return this.decorator.ensureHandles(record, element); } + removeHandles(element) { return this.decorator.removeHandles(element); } + refreshBoundaryReferences() { return this.decorator.refreshBoundaryReferences(); } + boundaryConceptFor(record, crossedConnector, inside) { return this.decorator.boundaryConceptFor(record, crossedConnector, inside); } + updateSubmapAnchorLine(record, bounds, surface) { return this.decorator.updateSubmapAnchorLine(record, bounds, surface); } updateSubmapDepth(record, depth) { record.submapDepth = depth; @@ -2540,175 +1435,12 @@ import { CmapView } from "./cmap-view.js"; } refreshSubmapVisibility() { - for (const item of this.items) item.node.visible(this.isEffectiveItemVisible(item)); - for (const connector of this.connectors) { - this.applyConnectorVisualEndpoints(connector, - this.connectorEndpoint(connector.source), - this.connectorEndpoint(connector.target)); - } - const submaps = this.items - .filter((item) => item.kind === "submap") - .sort((a, b) => b.submapDepth - a.submapDepth); - for (const submap of submaps) { - this.updateSubmapFrame(submap); - const element = submap.node.element(); - if (element) this.ensureSubmapToggle(submap, element); - } + this.submaps.refreshVisibility(); } updateSubmapFrame(record) { - const surface = this.surfaceElement(); - const shouldShow = record !== this.activeMapRoot && - record.expanded && this.isItemVisible(record); - if (!shouldShow || !surface) { - if (record.submapFrameElement) { - record.submapFrameElement.remove(); - record.submapFrameElement = null; - } - if (record.submapAnchorLineElement) { - record.submapAnchorLineElement.remove(); - record.submapAnchorLineElement = null; - } - return; - } - - if (!record.submapFrameElement) { - const frame = document.createElement("div"); - frame.className = "rw-cmap-submap-frame"; - frame.dataset.rwCmapSubmapId = String(record.id); - frame.tabIndex = 0; - frame.setAttribute("role", "group"); - frame.setAttribute("aria-label", record.label); - - frame.addEventListener("pointerdown", (event) => { - if (event.button !== 0) return; - if (this.pointNearConnector(this.canvasPoint(event))) return; - this.startSubmapFrameDrag(event, record); - }); - frame.addEventListener("dblclick", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.selectItem(record); - if (this.onEditItem) this.onEditItem(record); - }); - - const collapse = document.createElement("button"); - collapse.type = "button"; - collapse.className = "rw-cmap-submap-frame-toggle"; - collapse.textContent = "«"; - collapse.title = "Collapse submap"; - collapse.setAttribute("aria-label", collapse.title); - collapse.setAttribute("aria-expanded", "true"); - collapse.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - collapse.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.selectItem(record); - this.toggleSubmap(record, false); - }); - frame.append(collapse); - surface.prepend(frame); - record.submapFrameElement = frame; - } - - const bounds = this.submapBounds(record); - if (!bounds) return; - const { left, top, right, bottom } = bounds; - Object.assign(record.submapFrameElement.style, { - left: `${left}px`, - top: `${top}px`, - width: `${right - left}px`, - height: `${bottom - top}px` - }); - record.submapFrameElement.style.setProperty( - "--rw-cmap-submap-background", record.submapBackgroundColor || "#edf7e8"); - record.submapFrameElement.style.setProperty( - "--rw-cmap-submap-border", record.submapBorderColor || "#57834a"); - record.submapFrameElement.classList.toggle( - "rw-cmap-submap-frame-selected", this.selectedItems.has(record)); - record.submapFrameElement.classList.toggle( - "rw-cmap-submap-frame-selected-primary", - this.selectedItems.has(record) && this.selectedItem === record); - record.submapFrameElement.setAttribute("aria-label", record.label); - this.updateSubmapAnchorLine(record, bounds, surface); - this.ensureCanvasExtent(right, bottom); - } - - updateSubmapAnchorLine(record, bounds, surface = this.surfaceElement()) { - if (!surface || !bounds || record === this.activeMapRoot || !record.expanded) return; - if (!record.submapAnchorLineElement) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.classList.add("rw-cmap-submap-anchor-line"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - svg.append(path); - surface.prepend(svg); - record.submapAnchorLineElement = svg; - } - const anchor = this.itemCenter(record); - const target = { - x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)), - y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom)) - }; - record.submapAnchorLineElement.querySelector("path") - .setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`); - } - - startSubmapFrameDrag(event, record) { - event.preventDefault(); - event.stopPropagation(); - const additive = event.ctrlKey || event.metaKey || event.shiftKey; - if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(record)) { - this.selectedItem = record; - this.refreshSelectionDecoration(); - this.notifySelection(); - } else { - this.selectItem(record, { additive }); - } - const pointerId = event.pointerId; - const startClientX = event.clientX; - const startClientY = event.clientY; - const bounds = this.submapBounds(record); - if (!bounds) return; - const moveMembership = { - parent: record.parentSubmap, - parentBounds: null, - startX: bounds.left, - startY: bounds.top, - groupPositions: this.items - .filter((item) => this.isDescendantOf(item, record)) - .map((item) => ({ - item, - x: Number(item.node.attr("x")), - y: Number(item.node.attr("y")) - })) - }; - record.moveMembership = moveMembership; - - const move = (moveEvent) => { - if (moveEvent.pointerId !== pointerId) return; - moveEvent.preventDefault(); - const x = moveMembership.startX + - ((moveEvent.clientX - startClientX) / this.zoomFactor); - const y = moveMembership.startY + - ((moveEvent.clientY - startClientY) / this.zoomFactor); - this.moveSubmapGroup(record, x, y, moveMembership); - }; - - const up = (upEvent) => { - if (upEvent.pointerId !== pointerId) return; - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - record.moveMembership = null; - this.saveCurrentContextLayout(); - this.refreshSubmapVisibility(); - this.scheduleHistoryCommit(); - }; - - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); + this.submaps.refreshGroups(); + return this.submaps.diagramGroups.get(record)?.redraw() || null; } redrawConnectorsFor(record) { @@ -2721,108 +1453,15 @@ import { CmapView } from "./cmap-view.js"; } } - installMarqueeSelection() { - this.marqueeMouseDownHandler = (event) => { - if (event.button !== 0) return; - if (!(event.target instanceof Element)) return; - if (event.target.closest( - "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return; - - const start = this.canvasPoint(event); - if (this.pointNearConnector(start)) return; - if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); - const additive = event.ctrlKey || event.metaKey || event.shiftKey; - const surface = this.surfaceElement() || this.canvas; - const marquee = document.createElement("div"); - marquee.className = "rw-cmap-marquee"; - Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" }); - surface.append(marquee); - - const cleanup = () => { - window.removeEventListener("mousemove", move); - window.removeEventListener("mouseup", up); - marquee.remove(); - if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null; - }; - - const move = (moveEvent) => { - const point = this.canvasPoint(moveEvent); - const left = Math.min(start.x, point.x); - const top = Math.min(start.y, point.y); - Object.assign(marquee.style, { - left: `${left}px`, - top: `${top}px`, - width: `${Math.abs(point.x - start.x)}px`, - height: `${Math.abs(point.y - start.y)}px` - }); - }; - - const up = (upEvent) => { - const point = this.canvasPoint(upEvent); - cleanup(); - const bounds = { - left: Math.min(start.x, point.x), - top: Math.min(start.y, point.y), - right: Math.max(start.x, point.x), - bottom: Math.max(start.y, point.y) - }; - if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) { - if (!additive) this.clearSelection(); - return; - } - if (!additive) this.clearSelection(false); - const matches = this.items.filter((item) => { - if (!this.isEffectiveItemVisible(item)) return false; - const left = Number(item.node.attr("x")); - const top = Number(item.node.attr("y")); - const right = left + Number(item.node.attr("width")); - const bottom = top + Number(item.node.attr("height")); - return right >= bounds.left && left <= bounds.right && - bottom >= bounds.top && top <= bounds.bottom; - }); - const expanded = new Set(matches); - for (const item of matches) { - if (!item.groupId) continue; - for (const member of this.items.filter((candidate) => - candidate.groupId === item.groupId && this.isItemVisible(candidate))) expanded.add(member); - } - for (const item of expanded) this.selectedItems.add(item); - this.selectedItem = matches.at(-1) || this.selectedItem; - this.selectedConnector = null; - this.refreshSelectionDecoration(); - this.notifySelection(); - debug("marquee selection applied", { - selectedIds: this.selectedAll().map((item) => item.id) - }); - }; - - window.addEventListener("mousemove", move); - window.addEventListener("mouseup", up); - this.activeMarqueeCleanup = cleanup; - }; - this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler); - } - destroy() { this.destroyed = true; - if (this.historyTimer !== null) { - window.clearTimeout(this.historyTimer); - this.historyTimer = null; - } - if (this.marqueeMouseDownHandler) { - this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler); - this.marqueeMouseDownHandler = null; - } + this.history.destroy(); if (this.boundaryScrollHandler) { this.canvas.removeEventListener("scroll", this.boundaryScrollHandler); this.boundaryScrollHandler = null; } - if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); + this.interaction.destroy(); for (const item of this.items) { - if (item.submapFrameElement) { - item.submapFrameElement.remove(); - item.submapFrameElement = null; - } if (item.submapAnchorLineElement) { item.submapAnchorLineElement.remove(); item.submapAnchorLineElement = null; @@ -2884,7 +1523,6 @@ import { CmapView } from "./cmap-view.js"; } this.connectors = this.connectors.filter((connector) => !connectors.has(connector)); for (const record of records) { - if (record.submapFrameElement) record.submapFrameElement.remove(); if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id); record.node.remove(); this.model.conceptMap.removeItem(record.id); @@ -2908,330 +1546,6 @@ import { CmapView } from "./cmap-view.js"; return true; } - /** - * goal : Follow the component selected by cmap's coordinate hit test. - * pre : component is a public node/link wrapper or null. - * post : The corresponding wiki item or connector is selected; empty - * canvas space clears the selection. - */ - handleMapSelection(component, event) { - if (event.target instanceof Element && - event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) { - debug("cmap selection belongs to an editor control", elementDescription(event.target)); - return; - } - - const item = this.items.find((candidate) => candidate.node === component) || null; - const connector = this.connectors.find((candidate) => candidate.link === component) || null; - debug("selection callback received from cmap hit test", { - componentFound: Boolean(component), - itemId: item ? item.id : null, - connectorId: connector ? connector.id : null, - target: elementDescription(event.target) - }); - - if (item) { - const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey)); - if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(item)) { - this.selectedItem = item; - this.refreshSelectionDecoration(); - this.notifySelection(); - return; - } - this.selectItem(item, { additive, toggle: additive }); - return; - } - if (connector) { - this.selectConnector(connector); - return; - } - if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.clearSelection(); - } - - /** - * goal : Activate an item after cmap recognizes two stationary clicks. - * pre : component is the public wrapper returned by cmap's hit test. - * post : Page concepts navigate, submaps open and phrases enter editing. - */ - handleMapActivation(component, event) { - const item = this.items.find((candidate) => candidate.node === component) || null; - debug("activation callback received from cmap", { - itemId: item ? item.id : null, - kind: item ? item.kind : null, - pageSlug: item ? item.pageSlug : null - }); - if (!item) return; - if (event && event.preventDefault) event.preventDefault(); - if (item.kind === "phrase") { - this.editPhraseInline(item); - return; - } - if (item.kind === "submap") { - this.toggleSubmap(item); - return; - } - if (this.onEditItem) { - this.selectItem(item); - this.onEditItem(item); - return; - } - if (item.parentCmapLink) { - this.openParentMap(); - return; - } - if (item.cmapSlug && this.onOpenCmap) { - this.onOpenCmap(item); - return; - } - if (item.pageSlug && this.onOpenPage) { - this.onOpenPage(item); - return; - } - } - - ensureSubmapToggle(record, element) { - let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle"); - let open = element.querySelector(":scope > .rw-cmap-submap-open"); - if (record.kind !== "submap") { - if (toggle) toggle.remove(); - if (open) open.remove(); - return; - } - if (record === this.activeMapRoot) { - if (toggle) toggle.remove(); - if (open) open.remove(); - return; - } - const legacySeparateMap = record.separateMap && !record.cmapSlug; - if (record.expanded && !legacySeparateMap) { - if (toggle) toggle.remove(); - toggle = null; - } - if (!toggle) { - if (!record.expanded || legacySeparateMap) { - toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "rw-cmap-submap-toggle"; - toggle.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - toggle.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.selectItem(record); - this.toggleSubmap(record); - }); - element.append(toggle); - } - } - if (toggle) { - toggle.textContent = legacySeparateMap ? "↗" : "+"; - toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap"; - toggle.setAttribute("aria-label", toggle.title); - toggle.setAttribute("aria-expanded", String(record.expanded)); - } - if (record.cmapSlug && this.onOpenStoredSubMap) { - if (!open) { - open = document.createElement("button"); - open.type = "button"; - open.className = "rw-cmap-submap-open"; - open.textContent = "↗"; - open.title = "Open as separate concept map"; - open.setAttribute("aria-label", open.title); - open.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - open.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.selectItem(record); - this.onOpenStoredSubMap(record); - }); - element.append(open); - } - } else if (open) { - open.remove(); - } - } - - ensureHandles(record, element) { - if (!element.querySelector(":scope > .rw-cmap-relation-handle")) { - const relation = document.createElement("button"); - relation.type = "button"; - relation.className = "rw-cmap-handle rw-cmap-relation-handle"; - relation.title = this.labels.createRelation; - relation.setAttribute("aria-label", this.labels.createRelation); - relation.setAttribute("aria-hidden", "false"); - relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record)); - element.append(relation); - } - - if (record.kind !== "phrase" && - !element.querySelector(":scope > .rw-cmap-edit-handle")) { - const edit = document.createElement("button"); - edit.type = "button"; - edit.className = "rw-cmap-handle rw-cmap-edit-handle"; - edit.title = this.labels.editConcept; - edit.setAttribute("aria-label", this.labels.editConcept); - edit.setAttribute("aria-hidden", "false"); - edit.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - edit.addEventListener("mousedown", (event) => event.stopPropagation()); - edit.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.selectItem(record); - if (this.onEditItem) this.onEditItem(record); - }); - element.append(edit); - } - - if (record.kind !== "phrase" && - !element.querySelector(":scope > .rw-cmap-resize-handle")) { - const resize = document.createElement("button"); - resize.type = "button"; - resize.className = "rw-cmap-handle rw-cmap-resize-handle"; - resize.title = this.labels.resizeConcept; - resize.setAttribute("aria-label", this.labels.resizeConcept); - resize.setAttribute("aria-hidden", "false"); - resize.addEventListener("pointerdown", (event) => this.startResize(event, record)); - element.append(resize); - } - } - - removeHandles(element) { - for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove(); - } - - startResize(event, record) { - event.preventDefault(); - event.stopPropagation(); - record.autoWidth = false; - record.autoHeight = false; - record.fitContentPending = false; - const startX = event.clientX; - const startY = event.clientY; - const startWidth = Number(record.node.attr("width")); - const startHeight = Number(record.node.attr("height")); - const pointerId = event.pointerId; - event.currentTarget.setPointerCapture(pointerId); - - const move = (moveEvent) => { - if (moveEvent.pointerId !== pointerId) return; - record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor)); - record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor)); - record.node.attr({ width: record.width, height: record.height }); - record.node.redraw(); - this.decorateItem(record); - this.redrawConnectorsFor(record); - this.ensureCanvasExtent(Number(record.node.attr("x")) + record.width, - Number(record.node.attr("y")) + record.height); - }; - - const up = (upEvent) => { - if (upEvent.pointerId !== pointerId) return; - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - this.decorateItem(record); - this.scheduleHistoryCommit(); - }; - - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - } - - startRelationDrag(event, source) { - event.preventDefault(); - event.stopPropagation(); - const pointerId = event.pointerId; - const start = this.itemCenter(source); - const draft = this.createDraftLine(start); - this.dragRelation = { source, draft }; - event.currentTarget.setPointerCapture(pointerId); - - const move = (moveEvent) => { - if (moveEvent.pointerId !== pointerId) return; - const point = this.canvasPoint(moveEvent); - this.ensureCanvasExtent(point.x, point.y); - draft.line.setAttribute("x2", String(point.x)); - draft.line.setAttribute("y2", String(point.y)); - draft.svg.setAttribute("width", String(Math.max(this.logicalCanvasWidth(), point.x + 180))); - draft.svg.setAttribute("height", String(Math.max(this.logicalCanvasHeight(), point.y + 180))); - }; - - const up = (upEvent) => { - if (upEvent.pointerId !== pointerId) return; - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - const target = this.itemAt(upEvent.clientX, upEvent.clientY); - const point = this.canvasPoint(upEvent); - draft.svg.remove(); - this.dragRelation = null; - if (!target) { - this.ensureCanvasExtent(point.x, point.y); - const parentSubmap = this.submapAtPoint(point); - debug("relation dropped on empty canvas", { - sourceId: source.id, - point, - parentSubmapId: parentSubmap ? parentSubmap.id : null - }); - if (this.onCreateConnectedItem) this.onCreateConnectedItem({ source, point, parentSubmap }); - return; - } - if (target === source) return; - this.finishRelation(source, target, event.altKey || upEvent.altKey); - }; - - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - } - - finishRelation(source, target, direct = false) { - if (source.kind === "phrase" && target.kind !== "phrase") { - this.addConnector(source, target, true); - this.reconcilePhraseMembership(source); - this.refreshSubmapVisibility(); - this.selectItem(source); - return; - } - if (source.kind !== "phrase" && target.kind === "phrase") { - this.addConnector(source, target, false); - this.reconcilePhraseMembership(target); - this.refreshSubmapVisibility(); - this.selectItem(target); - return; - } - if (source.kind === "phrase" && target.kind === "phrase") return; - if (direct) { - const connector = this.addConnector(source, target, true); - this.refreshSubmapVisibility(); - this.selectConnector(connector); - return; - } - this.connectWithPhrase(source, target, "?????", true); - } - - createDraftLine(start) { - const ns = "http://www.w3.org/2000/svg"; - const svg = document.createElementNS(ns, "svg"); - svg.classList.add("rw-cmap-draft-layer"); - svg.setAttribute("width", String(this.logicalCanvasWidth())); - svg.setAttribute("height", String(this.logicalCanvasHeight())); - const line = document.createElementNS(ns, "line"); - line.setAttribute("x1", String(start.x)); - line.setAttribute("y1", String(start.y)); - line.setAttribute("x2", String(start.x)); - line.setAttribute("y2", String(start.y)); - line.setAttribute("class", "rw-cmap-draft-line"); - svg.append(line); - (this.surfaceElement() || this.canvas).append(svg); - return { svg, line }; - } - logicalCanvasWidth() { const surface = this.surfaceElement(); return Math.max(this.canvas.clientWidth / this.zoomFactor, diff --git a/static/cmap/cmap-utils.js b/static/cmap/cmap-utils.js new file mode 100644 index 0000000..b4d672b --- /dev/null +++ b/static/cmap/cmap-utils.js @@ -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); +} diff --git a/static/cmap/cmap-view.js b/static/cmap/cmap-view.js index 97d4459..d5cc8c8 100644 --- a/static/cmap/cmap-view.js +++ b/static/cmap/cmap-view.js @@ -1,3 +1,5 @@ +import { DiagramEngine } from "./cmap.js"; + /** * Present a CMap through the bundled drawing engine. * @@ -6,11 +8,13 @@ * rules; CmapEditor remains responsible for interpreting user interaction. */ export class CmapView { - constructor(canvas, CmapFactory, onSelection, onActivation) { + constructor(canvas, onSelection, onActivation, createEngine = null) { if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required"); - if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required"); + if (createEngine !== null && typeof createEngine !== "function") { + throw new TypeError("A diagram-engine factory must be a function"); + } this.canvas = canvas; - this.map = CmapFactory(canvas); + this.map = createEngine ? createEngine(canvas) : new DiagramEngine(canvas); this.map.onSelection(onSelection); this.map.onActivation(onActivation); } diff --git a/static/cmap/cmap.css b/static/cmap/cmap.css index 4ce8538..436aa15 100644 --- a/static/cmap/cmap.css +++ b/static/cmap/cmap.css @@ -362,11 +362,12 @@ body.cmap-mode #main { --cmap-a4-width: 1123px; --cmap-a4-height: 794px; position: relative; - flex: 1 0 auto; + z-index: 1; + flex: 1 1 0; width: max-content; min-width: max(760px, calc(100% - 90px)); min-height: 794px; - overflow: visible; + overflow: auto; border: 0; background-color: #fff; background-image: @@ -376,6 +377,12 @@ body.cmap-mode #main { box-shadow: inset 1px 1px #d9dde2; } +/* Keep the editor viewport bounded so vertical canvas scrolling is available. */ +.cmap-workspace > .cmap-canvas { + height: calc(100vh - 180px); + min-height: 0; +} + .cmap-canvas.cmap-page-guides-hidden { background-image: none; box-shadow: none; @@ -388,6 +395,15 @@ body.cmap-mode #main { transform-origin: 0 0; } +.cmap-canvas:has(.rw-cmap-surface) { + cursor: grab; +} + +.cmap-canvas.rw-cmap-canvas-panning { + cursor: grabbing; + user-select: none; +} + .rw-cmap-embed { display: block; margin: 1.25rem 0; @@ -773,7 +789,7 @@ body.cmap-mode #main { ); box-shadow: 0 1px 3px rgb(35 55 30 / 18%); cursor: move; - pointer-events: auto; + pointer-events: none; user-select: none; } @@ -822,6 +838,33 @@ body.cmap-mode #main { pointer-events: auto; } +.rw-cmap-submap-frame-drag-edge { + position: absolute; + z-index: 2; + cursor: move; + pointer-events: auto; +} + +.rw-cmap-submap-frame-drag-edge-top, +.rw-cmap-submap-frame-drag-edge-bottom { + right: 0; + left: 0; + height: 10px; +} + +.rw-cmap-submap-frame-drag-edge-top { top: 0; } +.rw-cmap-submap-frame-drag-edge-bottom { bottom: 0; } + +.rw-cmap-submap-frame-drag-edge-right, +.rw-cmap-submap-frame-drag-edge-left { + top: 0; + bottom: 0; + width: 10px; +} + +.rw-cmap-submap-frame-drag-edge-right { right: 0; } +.rw-cmap-submap-frame-drag-edge-left { left: 0; } + .rw-cmap-boundary-layer, .rw-cmap-boundary-lines { position: absolute; diff --git a/static/cmap/cmap.js b/static/cmap/cmap.js index 2cfde8c..c1e25e9 100644 --- a/static/cmap/cmap.js +++ b/static/cmap/cmap.js @@ -1,2305 +1,9 @@ /** - * racket-wiki cmap component 0.2.122 - * Based on cmap v0.1.3. - * (c) 2015 iOnStage - * Released under the MIT License. + * Public entry point for the racket-wiki diagram engine. + * + * CMap application code imports DiagramEngine explicitly. The Cmap alias is + * retained as an exported name for code that describes the visual surface as + * a CMap, without installing another global on window. */ - -(function() { - 'use strict'; - - var helper = {}; - - helper.toNumber = function(value, defaultValue) { - return !isNaN(value) ? +value : defaultValue; - }; - - helper.toString = function(value, defaultValue) { - return (typeof value !== 'undefined') ? '' + value : defaultValue; - }; - - helper.toBoolean = function(value, defaultValue) { - return (typeof value !== 'undefined') ? !!value : defaultValue; - }; - - helper.toContentType = function(value, defaultValue) { - if (value === helper.CONTENT_TYPE_TEXT || value === helper.CONTENT_TYPE_HTML) - return value; - - return defaultValue; - }; - - helper.isPlainObject = function(obj) { - return (typeof obj === 'object' && obj !== null && - Object.prototype.toString.call(obj) === '[object Object]'); - }; - - helper.inherits = function(ctor, superCtor) { - ctor.super_ = superCtor; - ctor.prototype = Object.create(superCtor.prototype, { - constructor: { - value: ctor, - enumerable: false, - writable: true, - configurable: true - } - }); - - return ctor; - }; - - helper.wrap = (function() { - var Wrapper = function(obj, key) { - this.obj = obj; - this.key = key; - - var wrapper = unwrap.bind(this); - var proto = Object.getPrototypeOf(obj); - - for (var key in proto) { - wrapper[key] = chain(proto[key], obj); - } - - return wrapper; - }; - - var unwrap = function(key) { - if (this.key === key) - return this.obj; - }; - - var chain = function(func, ctx) { - return function() { - var ret = func.apply(ctx, arguments); - - if (typeof ret === 'undefined') - return this; - - return ret; - }; - }; - - return function(obj, key) { - return new Wrapper(obj, key); - }; - })(); - - helper.deactivate = function(obj) { - for (var key in obj) { - delete obj[key]; - } - }; - - helper.eachInstance = function(array, ctor, callback) { - array.filter(function(obj) { - return obj instanceof ctor; - }).forEach(callback); - }; - - helper.firstInstance = function(array, ctor) { - return array.filter(function(obj) { - return obj instanceof ctor; - })[0]; - }; - - helper.diffObj = function(newObj, oldObj) { - var diff = {}; - - for (var key in newObj) { - if (!oldObj || newObj[key] !== oldObj[key]) - diff[key] = newObj[key]; - } - - return diff; - }; - - helper.pick = function(obj, keys) { - var ret = {}; - - if (!obj) - return ret; - - keys.forEach(function(key) { - if (key in obj) - ret[key] = obj[key]; - }); - - return ret; - }; - - helper.identity = function(value) { - return value; - }; - - helper.List = (function() { - var List = function() { - this.data = []; - }; - - List.prototype.add = function(item) { - if (!this.contains(item)) - this.data.push(item); - }; - - List.prototype.remove = function(item) { - var data = this.data; - - for (var i = data.length - 1; i >= 0; i--) { - if (this.equal(data[i], item)) { - data.splice(i, 1); - break; - } - } - }; - - List.prototype.contains = function(item) { - return this.data.some(function(dataItem) { - return this.equal(dataItem, item); - }.bind(this)); - }; - - List.prototype.equal = function(a, b) { - return a === b; - }; - - List.prototype.toArray = function() { - return this.data.slice(); - }; - - return List; - })(); - - helper.CONTENT_TYPE_TEXT = 'text'; - helper.CONTENT_TYPE_HTML = 'html'; - - 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(); - }; - - dom.draggable = (function() { - if (dom.disabled()) - return function() {}; - - var supportsTouch = dom.supportsTouch(); - var EVENT_TYPE_START = supportsTouch ? 'touchstart' : 'mousedown'; - var EVENT_TYPE_MOVE = supportsTouch ? 'touchmove' : 'mousemove'; - var EVENT_TYPE_END = supportsTouch ? 'touchend' : 'mouseup'; - - var Draggable = function(props) { - this.el = props.el; - this.onstart = props.onstart; - this.onmove = props.onmove; - this.onend = props.onend; - this.start = start.bind(this); - this.move = move.bind(this); - this.end = end.bind(this); - this.lock = false; - this.startingPoint = null; - - dom.on(this.el, EVENT_TYPE_START, this.start); - }; - - var start = function(event) { - if (this.lock) - return; - - this.lock = true; - this.startingPoint = dom.pagePoint(event); - - var el = this.el; - var onstart = this.onstart; - - var rect = dom.rect(el); - var p = dom.clientPoint(event, { - x: rect.left - dom.scrollLeft(el), - y: rect.top - dom.scrollTop(el) - }); - - if (typeof onstart === 'function') - onstart(p.x, p.y, event); - - dom.on(document, EVENT_TYPE_MOVE, this.move); - dom.on(document, EVENT_TYPE_END, this.end); - }; - - var move = function(event) { - var onmove = this.onmove; - var d = dom.pagePoint(event, this.startingPoint); - - if (typeof onmove === 'function') - onmove(d.x, d.y, event); - }; - - var end = function(event) { - dom.off(document, EVENT_TYPE_MOVE, this.move); - dom.off(document, EVENT_TYPE_END, this.end); - - var onend = this.onend; - var d = dom.pagePoint(event, this.startingPoint); - - if (typeof onend === 'function') - onend(d.x, d.y, event); - - this.lock = false; - }; - - return function(el, onstart, onmove, onend) { - new Draggable({ - el: el, - onstart: onstart, - onmove: onmove, - onend: onend - }); - }; - })(); - - var Component = function() {}; - - Component.prototype.disposed = false; - - Component.prototype.dispose = function() { - this.disposed = true; - }; - - Component.prototype.prop = function(initialValue, defaultValue, converter) { - if (typeof converter !== 'function') - converter = helper.identity; - - var cache = converter(initialValue, defaultValue); - - return function(value) { - if (typeof value === 'undefined') - return cache; - - if (value === cache) - return; - - cache = converter(value, cache); - - this.markDirty(); - }; - }; - - Component.prototype.relations = function() { - return []; - }; - - Component.prototype.redraw = function() {}; - - Component.prototype.notifyRendered = function() { - if (typeof this.renderedHandler === 'function' && this.element()) - this.renderedHandler(this.element()); - }; - - Component.prototype.markDirty = (function() { - var dirtyComponents = []; - var requestId = null; - - var updateRelations = function(index) { - for (var i = index, len = dirtyComponents.length; i < len; i++) { - var component = dirtyComponents[i]; - if (component.disposed) - continue; - component.relations().forEach(function(relation) { - if (!relation.disposed) - relation.update(component); - }); - } - - // may be inserted other dirty components by updating relations - if (dirtyComponents.length > len) - updateRelations(len); - }; - - var callback = function() { - updateRelations(0); - - dirtyComponents.forEach(function(component) { - if (!component.disposed) - component.redraw(); - }); - - dirtyComponents = []; - requestId = null; - }; - - return function() { - if (dom.disabled() || this.disposed) - return; - - if (dirtyComponents.indexOf(this) === -1) - dirtyComponents.push(this); - - if (requestId !== null) - return; - - requestId = dom.animate(callback); - }; - })(); - - var Node = helper.inherits(function(props) { - 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; - }, Component); - - Node.prototype.cx = function() { - return this.x() + this.width() / 2; - }; - - Node.prototype.cy = function() { - return this.y() + this.height() / 2; - }; - - Node.prototype.borderRadius = function() { - return 4; - }; - - Node.prototype.contains = function(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); - }; - - Node.prototype.style = function() { - 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() - }; - }; - - Node.prototype.redraw = function() { - var element = this.element(); - var parentElement = this.parentElement(); - - if (!parentElement && !element) - return; - - // add element - if (parentElement && !element) { - element = dom.el('
'); - 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(); - }; - - var Link = helper.inherits(function(props) { - 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; - }, Component); - - Link.prototype.straighten = function(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); - }; - - Link.prototype.contains = function(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; - }; - - Link.prototype.containsPath = function(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; - }; - - Link.prototype.style = function() { - return { - display: this.visible ? '' : 'none', - pointerEvents: 'none', - position: 'absolute', - zIndex: this.zIndex() - }; - }; - - Link.prototype.pathContainerStyle = function() { - 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' - }; - }; - - Link.prototype.lineAttributes = function() { - 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() - }; - }; - - Link.prototype.arrowAttributes = function() { - 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' - }; - }; - - Link.prototype.contentStyle = function() { - 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' - }; - }; - - Link.prototype.redraw = function() { - var element = this.element(); - var parentElement = this.parentElement(); - - if (!parentElement && !element) - return; - - // add element - if (parentElement && !element) { - element = dom.el('
'); - dom.html(element, '
'); - 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(); - }; - - var Connector = helper.inherits(function(props) { - 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([]); - }, Component); - - Connector.prototype.r = function() { - return 16; - }; - - Connector.prototype.contains = function(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); - }; - - Connector.prototype.style = function() { - 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() - }; - }; - - Connector.prototype.redraw = function() { - var element = this.element(); - var parentElement = this.parentElement(); - - if (!parentElement && !element) - return; - - // add element - if (parentElement && !element) { - element = dom.el('
'); - 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'; - - var Relation = function() {}; - - Relation.prototype.prop = function(initialValue) { - var cache = initialValue; - - return function(value) { - if (typeof value === 'undefined') - return cache; - - cache = value; - }; - }; - - Relation.prototype.update = function() {}; - - var Triple = helper.inherits(function(props) { - 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({}); - }, Relation); - - Triple.prototype.update = function(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); - }; - - Triple.prototype.updateNode = function(link, sourceNode, targetNode, changedNode) { - if (sourceNode && targetNode) - this.rotateLink(link, sourceNode, targetNode, changedNode); - else - this.shiftLink(link, sourceNode, targetNode, changedNode); - - this.updateNodePositionsCache(); - }; - - Triple.prototype.rotateLink = function(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); - }; - - Triple.prototype.shiftLink = function(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)); - } - }; - - Triple.prototype.updateLink = function(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); - } - }; - - Triple.prototype.updateLinkAngle = function(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); - }; - - Triple.prototype.updateNodePositionsCache = function() { - 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(); - } - }; - - Triple.prototype.connectedPoint = function(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 - }; - }; - - var LinkConnectorRelation = helper.inherits(function(props) { - this.type = this.prop(props.type); - this.link = this.prop(props.link); - this.connector = this.prop(props.connector); - }, Relation); - - LinkConnectorRelation.prototype.isConnected = function(isConnected) { - var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED; - this.connector().color(color); - }; - - LinkConnectorRelation.prototype.update = function(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']()); - } - }; - - var ComponentList = helper.inherits(function() { - ComponentList.super_.call(this); - }, helper.List); - - ComponentList.prototype.toFront = function(component) { - var data = this.data; - var index = data.indexOf(component); - - if (index === -1) - return; - - data.splice(index, 1); - data.push(component); - }; - - ComponentList.prototype.fromPoint = function(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; - }; - - var DisabledConnectorList = helper.inherits(function() { - DisabledConnectorList.super_.call(this); - }, helper.List); - - DisabledConnectorList.prototype.add = function(type, link) { - DisabledConnectorList.super_.prototype.add.call(this, { - type: type, - link: link - }); - }; - - DisabledConnectorList.prototype.remove = function(type, link) { - DisabledConnectorList.super_.prototype.remove.call(this, { - type: type, - link: link - }); - }; - - DisabledConnectorList.prototype.contains = function(type, link) { - return DisabledConnectorList.super_.prototype.contains.call(this, { - type: type, - link: link - }); - }; - - DisabledConnectorList.prototype.equal = function(a, b) { - return a.type === b.type && a.link === b.link; - }; - - var Cmap = helper.inherits(function(rootElement) { - 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(); - }, Component); - - Cmap.LINK_Z_INDEX_BASE = 100; - Cmap.NODE_Z_INDEX_BASE = 100000; - Cmap.CONNECTOR_Z_INDEX_BASE = 200000; - - Cmap.prototype.add = function(component) { - component.parentElement(this.element()); - this.componentList().add(component); - this.updateZIndex(); - }; - - Cmap.prototype.remove = function(component) { - component.parentElement(null); - - if (component instanceof Link) - this.hideConnectors(component); - - this.disconnect(component); - this.componentList().remove(component); - this.updateZIndex(); - }; - - Cmap.prototype.toFront = function(component) { - this.componentList().toFront(component); - this.updateZIndex(); - }; - - Cmap.prototype.updateZIndex = function() { - 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); - }); - }); - }; - - Cmap.prototype.connect = function(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(); - }; - - Cmap.prototype.disconnect = function(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(); - }; - - Cmap.prototype.connectedNode = function(type, link) { - var triple = helper.firstInstance(link.relations(), Triple); - - if (!triple) - return null; - - return triple[type + 'Node'](); - }; - - Cmap.prototype.showConnector = function(type, link) { - if (this.connectorVisible(type, link)) - return; - - var disabledConnectorList = this.disabledConnectorList(); - var connectorDisabled = disabledConnectorList.contains(type, link); - - if (!connectorDisabled) - this.addConnector(type, link); - }; - - Cmap.prototype.connectorVisible = function(type, link) { - return link.relations().some(function(relation) { - return relation instanceof LinkConnectorRelation && relation.type() === type; - }); - }; - - Cmap.prototype.addConnector = function(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); - }; - - Cmap.prototype.hideConnector = function(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; - } - }; - - Cmap.prototype.showConnectors = function(link) { - this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link); - this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link); - }; - - Cmap.prototype.hideConnectors = function(link) { - this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link); - this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link); - }; - - Cmap.prototype.hideAllConnectors = function() { - this.componentList().toArray().forEach(function(component) { - if (component instanceof Link) - this.hideConnectors(component); - }.bind(this)); - }; - - Cmap.prototype.enableConnector = function(type, link) { - this.disabledConnectorList().remove(type, link); - }; - - Cmap.prototype.disableConnector = function(type, link) { - // remove showing connector - this.hideConnector(type, link); - - this.disabledConnectorList().add(type, link); - }; - - Cmap.prototype.connectorEnabled = function(type, link) { - return !this.disabledConnectorList().contains(type, link); - }; - - Cmap.prototype.enableDrag = function(component) { - this.dragDisabledComponentList().remove(component); - }; - - Cmap.prototype.disableDrag = function(component) { - this.dragDisabledComponentList().add(component); - }; - - Cmap.prototype.dragEnabled = function(component) { - return !this.dragDisabledComponentList().contains(component); - }; - - Cmap.prototype.onstart = function(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(); - }; - - Cmap.prototype.onmove = function(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(); - } - } - }; - - Cmap.prototype.onend = function(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(); - }; - - Cmap.prototype.fixScrollSize = function() { - 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 - }); - }; - - Cmap.prototype.unfixScrollSize = function() { - var translate = 'translate(-1px, -1px)'; - - dom.css(this.retainerElement(), { - msTransform: translate, - transform: translate, - webkitTransform: translate - }); - }; - - Cmap.prototype.style = function() { - 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 - }; - }; - - Cmap.prototype.retainerStyle = function() { - return { - height: '1px', - pointerEvents: 'none', - position: 'absolute', - width: '1px' - }; - }; - - Cmap.prototype.redraw = function() { - 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('
'); - 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('
'); - 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.anotherConnectionType = function(type) { - if (type === Cmap.CONNECTION_TYPE_SOURCE) - return Cmap.CONNECTION_TYPE_TARGET; - else if (type === Cmap.CONNECTION_TYPE_TARGET) - return Cmap.CONNECTION_TYPE_SOURCE; - }; - - Cmap.CONNECTION_TYPE_SOURCE = 'source'; - Cmap.CONNECTION_TYPE_TARGET = 'target'; - - var ComponentModule = function(component, cmap) { - this.component = component; - this.cmap = cmap; - this.wrapper = helper.wrap(this, cmap.component); - component.module = this; - }; - - ComponentModule.prototype.attr = function(key, value) { - var component = this.component; - var attributeKeys = this.constructor.attributeKeys(); - - if (typeof key === 'undefined') { - var props = {}; - - attributeKeys.forEach(function(key) { - props[key] = component[key](); - }); - - return props; - } - - if (helper.isPlainObject(key)) { - var props = key; - - for (key in props) { - this.attr(key, props[key]); - } - - return; - } - - if (attributeKeys.indexOf(key) === -1) - return; - - if (typeof value === 'undefined') - return component[key](); - - component[key](value); - }; - - ComponentModule.prototype.remove = function() { - this.cmap.component.remove(this.component); - helper.deactivate(this.wrapper); - - this.component = null; - this.cmap = null; - this.wrapper = null; - }; - - ComponentModule.prototype.toFront = function() { - this.cmap.component.toFront(this.component); - }; - - ComponentModule.prototype.element = function() { - return this.component.element(); - }; - - ComponentModule.prototype.redraw = function() { - this.component.redraw(); - }; - - ComponentModule.prototype.visible = function(value) { - if (typeof value === 'undefined') - return this.component.visible !== false; - - this.component.visible = !!value; - this.component.redraw(); - return this.component.visible; - }; - - ComponentModule.prototype.onRendered = function(handler) { - var module = this; - var component = this.component; - - if (handler !== null && typeof handler !== 'function') - throw TypeError('Invalid render handler'); - - component.renderedHandler = handler ? function(element) { - handler(module.wrapper, element); - } : null; - - if (handler && component.element()) - handler(module.wrapper, component.element()); - }; - - ComponentModule.prototype.draggable = function(enabled) { - var component = this.component; - var cmap = this.cmap; - - if (typeof enabled === 'undefined') - return cmap.component.dragEnabled(component); - - if (enabled) - cmap.component.enableDrag(component); - else - cmap.component.disableDrag(component); - }; - - ComponentModule.attributeKeys = function() { - return []; - }; - - var NodeModule = helper.inherits(function(props, cmap) { - var component = new Node(helper.pick(props, NodeModule.attributeKeys())); - - NodeModule.super_.call(this, component, cmap); - }, ComponentModule); - - NodeModule.prototype.remove = function() { - this.cmap.nodeModuleList.remove(this); - NodeModule.super_.prototype.remove.call(this); - }; - - NodeModule.prototype.onMove = function(handler) { - var module = this; - - if (handler !== null && typeof handler !== 'function') - throw TypeError('Invalid move handler'); - - this.component.moveHandler = handler ? function(x, y) { - return handler(module.wrapper, x, y); - } : null; - }; - - NodeModule.prototype.onMoveEnd = function(handler) { - var module = this; - - if (handler !== null && typeof handler !== 'function') - throw TypeError('Invalid move-end handler'); - - this.component.moveEndHandler = handler ? function(x, y, event) { - handler(module.wrapper, x, y, event); - } : null; - }; - - NodeModule.attributeKeys = function() { - return [ - 'content', - 'contentType', - 'x', - 'y', - 'width', - 'height', - 'backgroundColor', - 'borderColor', - 'borderWidth', - 'textColor' - ]; - }; - - var LinkModule = helper.inherits(function(props, cmap) { - var component = new Link(helper.pick(props, LinkModule.attributeKeys())); - - LinkModule.super_.call(this, component, cmap); - }, ComponentModule); - - LinkModule.prototype.sourceNode = function(node) { - return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_SOURCE, node); - }; - - LinkModule.prototype.targetNode = function(node) { - return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_TARGET, node); - }; - - LinkModule.prototype.onConnectionChange = function(handler) { - var module = this; - - if (handler !== null && typeof handler !== 'function') - throw TypeError('Invalid connection-change handler'); - - this.component.connectionChangeHandler = handler ? function(type, node, event) { - var nodeModule = node ? module.cmap.nodeModuleList.fromComponent(node) : null; - handler(module.wrapper, type, nodeModule ? nodeModule.wrapper : null, event); - } : null; - }; - - LinkModule.prototype.straighten = function() { - var link = this.component; - var triple = helper.firstInstance(link.relations(), Triple); - var sourceNode = triple ? triple.sourceNode() : null; - var targetNode = triple ? triple.targetNode() : null; - - if (!sourceNode || !targetNode) { - link.straighten(); - return; - } - - var sourcePoint = triple.connectedPoint(sourceNode, targetNode.cx(), targetNode.cy()); - var targetPoint = triple.connectedPoint(targetNode, sourceNode.cx(), sourceNode.cy()); - link.straighten(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y); - }; - - LinkModule.prototype.sourceConnectorEnabled = function(enabled) { - return LinkModule.connectorEnabled(this, Cmap.CONNECTION_TYPE_SOURCE, enabled); - }; - - LinkModule.prototype.targetConnectorEnabled = function(enabled) { - return LinkModule.connectorEnabled(this, Cmap.CONNECTION_TYPE_TARGET, enabled); - }; - - LinkModule.attributeKeys = function() { - return [ - 'content', - 'contentType', - 'cx', - 'cy', - 'width', - 'height', - 'backgroundColor', - 'borderColor', - 'borderWidth', - 'textColor', - 'sourceX', - 'sourceY', - 'targetX', - 'targetY', - 'lineColor', - 'lineWidth', - 'hasArrow' - ]; - }; - - LinkModule.connectNode = function(module, type, node) { - var component = module.component; - var cmap = module.cmap; - - var cmapComponent = cmap.component; - var connectedNodeComponent = cmapComponent.connectedNode(type, component); - - if (typeof node === 'undefined') { - if (!connectedNodeComponent) - return null; - - var connectedNode = cmap.nodeModuleList.fromComponent(connectedNodeComponent); - return connectedNode.wrapper; - } - - if (node !== null) { - if (typeof node !== 'function') - throw TypeError('Invalid node'); - - // unwrap node module - node = node(cmap.component); - - if (!(node instanceof NodeModule)) - throw TypeError('Invalid node'); - - // cannot connect the same node to the source and the target of the link - var anotherType = Cmap.anotherConnectionType(type); - var anotherNodeComponent = cmapComponent.connectedNode(anotherType, component); - - if (anotherNodeComponent === node.component) - return; - } - - if (connectedNodeComponent) - cmapComponent.disconnect(type, connectedNodeComponent, component); - - if (node === null) - return; - - cmapComponent.connect(type, node.component, component); - }; - - LinkModule.connectorEnabled = function(module, type, enabled) { - var component = module.component; - var cmap = module.cmap; - - var cmapComponent = cmap.component; - - if (typeof enabled === 'undefined') - return cmapComponent.connectorEnabled(type, component); - - if (enabled) { - cmapComponent.enableConnector(type, component); - - // show the connector if another connector is showing - var anotherType = Cmap.anotherConnectionType(type); - - if (cmapComponent.connectorVisible(anotherType, component)) - cmapComponent.showConnector(type, component); - } else { - cmapComponent.disableConnector(type, component); - } - }; - - var NodeModuleList = helper.inherits(function() { - NodeModuleList.super_.call(this); - }, helper.List); - - NodeModuleList.prototype.fromComponent = function(component) { - var data = this.data; - - for (var i = 0, len = data.length; i < len; i++) { - var node = data[i]; - - if (node.component === component) - return node; - } - - return null; - }; - - var CmapModule = function(element) { - if (!(this instanceof CmapModule)) - return new CmapModule(element); - - this.component = new Cmap(element); - this.nodeModuleList = new NodeModuleList(); - this.selectionHandler = null; - this.activationHandler = null; - - this.component.selectionHandler = function(component, event) { - if (typeof this.selectionHandler !== 'function') - return; - - var module = component ? component.module : null; - this.selectionHandler(module ? module.wrapper : null, event); - }.bind(this); - - this.component.activationHandler = function(component, event) { - if (typeof this.activationHandler !== 'function') - return; - - var module = component ? component.module : null; - this.activationHandler(module ? module.wrapper : null, event); - }.bind(this); - - return helper.wrap(this, this.component); - }; - - CmapModule.prototype.onSelection = function(handler) { - if (handler !== null && typeof handler !== 'function') - throw TypeError('Invalid selection handler'); - - this.selectionHandler = handler; - }; - - CmapModule.prototype.onActivation = function(handler) { - if (handler !== null && typeof handler !== 'function') - throw TypeError('Invalid activation handler'); - - this.activationHandler = handler; - }; - - CmapModule.prototype.destroy = function() { - var component = this.component; - var element = component.element(); - - this.selectionHandler = null; - this.activationHandler = null; - component.selectionHandler = null; - component.activationHandler = null; - component.componentList().toArray().forEach(function(child) { - child.dispose(); - child.parentElement(null); - }); - component.dispose(); - - if (element && element.parentNode) - element.parentNode.removeChild(element); - }; - - CmapModule.prototype.zoom = function(value) { - if (typeof value === 'undefined') - return this.component.zoomFactor; - - value = Number(value); - if (!isFinite(value) || value <= 0) - throw TypeError('Invalid zoom factor'); - - this.component.zoomFactor = value; - var element = this.component.element(); - if (element) - element.style.zoom = String(value); - return value; - }; - - CmapModule.prototype.node = function(props) { - if (typeof props !== 'undefined' && !helper.isPlainObject(props)) - throw TypeError('Type error'); - - var node = new NodeModule(props, this); - - this.component.add(node.component); - this.nodeModuleList.add(node); - - return node.wrapper; - }; - - CmapModule.prototype.link = function(props) { - if (typeof props !== 'undefined' && !helper.isPlainObject(props)) - throw TypeError('Type error'); - - var link = new LinkModule(props, this); - - this.component.add(link.component); - - return link.wrapper; - }; - - if (typeof module !== 'undefined' && module.exports) - module.exports = CmapModule; - else - window.Cmap = CmapModule; -})(); +export { DiagramEngine, DiagramEngine as Cmap } from "./engine/diagram-engine.js"; +export { DiagramGroup } from "./engine/diagram-group.js"; diff --git a/static/cmap/controller/cmap-history.js b/static/cmap/controller/cmap-history.js new file mode 100644 index 0000000..8a201b1 --- /dev/null +++ b/static/cmap/controller/cmap-history.js @@ -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(); + } +} diff --git a/static/cmap/controller/cmap-interaction-controller.js b/static/cmap/controller/cmap-interaction-controller.js new file mode 100644 index 0000000..43e5edc --- /dev/null +++ b/static/cmap/controller/cmap-interaction-controller.js @@ -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(); + } +} diff --git a/static/cmap/controller/cmap-selection-controller.js b/static/cmap/controller/cmap-selection-controller.js new file mode 100644 index 0000000..4692fce --- /dev/null +++ b/static/cmap/controller/cmap-selection-controller.js @@ -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()); + } + } +} diff --git a/static/cmap/controller/cmap-submap-controller.js b/static/cmap/controller/cmap-submap-controller.js new file mode 100644 index 0000000..eb069c1 --- /dev/null +++ b/static/cmap/controller/cmap-submap-controller.js @@ -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); + } + } +} diff --git a/static/cmap/engine/diagram-component.js b/static/cmap/engine/diagram-component.js new file mode 100644 index 0000000..b02b8f6 --- /dev/null +++ b/static/cmap/engine/diagram-component.js @@ -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]"; +} diff --git a/static/cmap/engine/diagram-engine.js b/static/cmap/engine/diagram-engine.js new file mode 100644 index 0000000..75af8f7 --- /dev/null +++ b/static/cmap/engine/diagram-engine.js @@ -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"); + } +} diff --git a/static/cmap/engine/diagram-group.js b/static/cmap/engine/diagram-group.js new file mode 100644 index 0000000..69bab93 --- /dev/null +++ b/static/cmap/engine/diagram-group.js @@ -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; + } + } +} diff --git a/static/cmap/engine/diagram-link.js b/static/cmap/engine/diagram-link.js new file mode 100644 index 0000000..be7d7ef --- /dev/null +++ b/static/cmap/engine/diagram-link.js @@ -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; + } +} diff --git a/static/cmap/engine/diagram-node.js b/static/cmap/engine/diagram-node.js new file mode 100644 index 0000000..1452079 --- /dev/null +++ b/static/cmap/engine/diagram-node.js @@ -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; + } +} diff --git a/static/cmap/engine/drawing-collections.js b/static/cmap/engine/drawing-collections.js new file mode 100644 index 0000000..0daf660 --- /dev/null +++ b/static/cmap/engine/drawing-collections.js @@ -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 }; diff --git a/static/cmap/engine/drawing-components.js b/static/cmap/engine/drawing-components.js new file mode 100644 index 0000000..36e6a81 --- /dev/null +++ b/static/cmap/engine/drawing-components.js @@ -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('
'); + 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('
'); + dom.html(element, '
'); + 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('
'); + 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 }; diff --git a/static/cmap/engine/drawing-core.js b/static/cmap/engine/drawing-core.js new file mode 100644 index 0000000..d3516b3 --- /dev/null +++ b/static/cmap/engine/drawing-core.js @@ -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"; diff --git a/static/cmap/engine/drawing-relations.js b/static/cmap/engine/drawing-relations.js new file mode 100644 index 0000000..964b84e --- /dev/null +++ b/static/cmap/engine/drawing-relations.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 }; diff --git a/static/cmap/engine/drawing-support.js b/static/cmap/engine/drawing-support.js new file mode 100644 index 0000000..43456ba --- /dev/null +++ b/static/cmap/engine/drawing-support.js @@ -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 }; diff --git a/static/cmap/engine/drawing-surface.js b/static/cmap/engine/drawing-surface.js new file mode 100644 index 0000000..ae1cd0a --- /dev/null +++ b/static/cmap/engine/drawing-surface.js @@ -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('
'); + 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('
'); + 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 }; diff --git a/static/cmap/model/interchange.js b/static/cmap/model/interchange.js index 9e72837..cbcabed 100644 --- a/static/cmap/model/interchange.js +++ b/static/cmap/model/interchange.js @@ -1,4 +1,13 @@ -/* Versioned JSON interchange for the Racket Wiki CMap model. */ +/** + * Versioned JSON interchange for the Racket Wiki CMap model. + * + * The bundle separates shared concept content from map-local placement data. + * `buildBundle` follows map and page references, embeds the referenced page + * attachments, and validates the resulting object. Import code calls + * `validateBundle` first and then uses `preparedMapDocument` to join shared + * concepts back into a map document. The helpers in this module deliberately + * return detached JSON values so callers cannot mutate an input map or bundle. + */ const FORMAT = "racket-wiki-cmap-bundle"; const FORMAT_VERSION = 1; @@ -12,10 +21,15 @@ ]; const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id")); + /** Return a detached JSON-compatible copy while preserving undefined. */ function clone(value) { return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } + /** + * Check a wiki page reference in either `slug` or `namespace:slug` form. + * The check is shared by concept links, map metadata and exported pages. + */ function validPageReference(value) { if (typeof value !== "string") return false; const separator = value.indexOf(":"); @@ -25,6 +39,7 @@ return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug); } + /** Accept only complete HTTP(S) URLs for external concept links. */ function validExternalUrl(value) { try { const url = new URL(String(value)); @@ -34,6 +49,11 @@ } } + /** + * Decode the one or two historical JSON string wrappers used by storage. + * Invalid or non-object values become an empty document; bundle validation + * remains responsible for rejecting malformed imported data. + */ function decodedDocument(value) { let documentValue = value; for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) { @@ -43,6 +63,7 @@ documentValue : {}; } + /** Copy only shared concept fields into the bundle-level concept table. */ function conceptContent(value) { const result = {}; for (const key of CONCEPT_KEYS) { @@ -51,18 +72,21 @@ return result; } + /** Index valid document concepts by their stable identity. */ function conceptsById(documentValue) { return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : []) .filter((concept) => concept && typeof concept.id === "string" && concept.id) .map((concept) => [concept.id, concept])); } + /** Return unique concept ids used by non-phrase placements. */ function itemConceptIds(documentValue) { return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : []) .filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId) .map((item) => item.conceptId))]; } + /** Find linked CMap slugs in the concepts actually placed on a document. */ function linkedMapSlugs(documentValue) { const concepts = conceptsById(documentValue); return [...new Set(itemConceptIds(documentValue) @@ -70,6 +94,11 @@ .filter(Boolean))]; } + /** + * Collect page references from map metadata and placed concepts. + * Unplaced concept records are intentionally ignored because they are not + * part of the visible map dependency graph. + */ function linkedPageReferences(documentValue) { const references = new Set(); const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ? @@ -87,6 +116,11 @@ return references; } + /** + * Extract local upload URLs from Markdown and HTML-like markup. + * A Set removes duplicates; the final prefix check removes a shorter URL + * accidentally captured from a URL containing a space. + */ function attachmentUrls(markdown) { const source = String(markdown || ""); const urls = new Set(); @@ -102,6 +136,7 @@ other !== url && other.startsWith(`${url} `))); } + /** Derive a readable fallback filename from an upload URL. */ function attachmentName(url) { const encoded = String(url || "").split("/").at(-1) || "attachment.bin"; try { @@ -111,6 +146,7 @@ } } + /** Replace upload URLs after imported attachments receive new server URLs. */ function replaceAttachmentUrls(markdown, replacements) { let result = String(markdown || ""); const entries = replacements instanceof Map ? [...replacements.entries()] : @@ -123,6 +159,11 @@ return result; } + /** + * Strip shared concept fields from placements while preserving layout data. + * It also keeps only connectors whose two local item endpoints still exist, + * because export must not emit dangling layout relations. + */ function placementDocument(documentValue) { const documentCopy = clone(decodedDocument(documentValue)); const ids = itemConceptIds(documentCopy); @@ -149,6 +190,10 @@ return documentCopy; } + /** + * Convert one wiki page and all uploads referenced by its Markdown into a + * bundle page record. The attachment loader is called once per unique URL. + */ async function pageRecord(page, requestedReference, loadAttachment) { const markdown = String(page.markdown || ""); const attachments = []; @@ -176,6 +221,24 @@ }; } + /** + * Build and validate a complete export bundle. + * + * @param {object} options Export options and asynchronous repository loaders. + * @param {object} options.rootMap Root map with `slug`, `title` and `document`. + * @param {Function} options.loadConceptMap Loads a linked map by slug. + * @param {Function} options.loadWikiPage Loads a linked wiki page by reference. + * @param {Function} [options.loadAttachment] Loads base64 upload content. + * @param {number} [options.maxDepth=0] Maximum depth for linked CMaps. + * @returns {Promise} A validated, detached interchange bundle. + * @throws {Error} When required loaders are absent or a loaded record is invalid. + * @sideeffects Calls the supplied map, page and attachment loaders. + * + * `collectMap` traverses map dependencies and uses `placementDocument` for + * layout-only map records. It collects shared concepts and page references + * separately; page records are then built and the final aggregate is checked + * by `validateBundle` before it is returned. + */ async function buildBundle(options) { if (!options?.rootMap?.slug) throw new Error("A root CMap is required."); if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required."); @@ -248,6 +311,18 @@ return bundle; } + /** + * Validate the complete bundle contract and return the original bundle. + * + * @param {object} bundle Candidate bundle to validate. + * @returns {object} The same bundle object after successful validation. + * @throws {Error} With `validationErrors` when one or more contract checks fail. + * + * Validation also builds the concept, map, item and page-reference indexes + * needed to detect duplicate identities, dangling references and missing + * linked pages. Up to twenty errors are included in the message while the + * complete list remains available on `error.validationErrors`. + */ function validateBundle(bundle) { const errors = []; const issue = (path, message) => errors.push(`${path}: ${message}`); @@ -260,6 +335,7 @@ if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array"); if (!Array.isArray(bundle.pages)) issue("pages", "must be an array"); + // Validate the shared concept table and collect its page dependencies. const conceptIds = new Set(); const conceptLabels = new Set(); const usedConceptIds = new Set(); @@ -297,6 +373,7 @@ } } + // Validate map identities, placements, connectors and map-level page links. const mapSlugs = new Set(); for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) { const path = `cmaps[${mapIndex}]`; @@ -362,6 +439,7 @@ if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement"); } + // Validate page records and their embedded attachment payloads. const pageReferences = new Set(); for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) { const path = `pages[${index}]`; @@ -405,6 +483,7 @@ } } } + // Reconcile all collected page dependencies with present or declared-missing pages. const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []); for (const reference of linkedPageReferences) { if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) { @@ -412,6 +491,7 @@ } } + // Report all discovered issues together so import callers can repair a bundle in one pass. if (errors.length) { const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`); error.validationErrors = errors; @@ -420,6 +500,14 @@ return bundle; } + /** + * Rejoin bundle-level concept content with one placement-only map document. + * + * @param {object} bundle A bundle that satisfies `validateBundle`. + * @param {object} cmap One entry from `bundle.cmaps`. + * @returns {object} A detached document suitable for repository import. + * @throws {Error} When the bundle is invalid or a referenced concept is absent. + */ function preparedMapDocument(bundle, cmap) { validateBundle(bundle); const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept])); diff --git a/static/cmap/view/cmap-item-decorator.js b/static/cmap/view/cmap-item-decorator.js new file mode 100644 index 0000000..dba59e1 --- /dev/null +++ b/static/cmap/view/cmap-item-decorator.js @@ -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 `
${escapeHtml(record.label || "?????")}
`; + } + if (this.editor.renderItem) return this.editor.renderItem(record); + return `
${escapeHtml(record.label)}
`; + } + + editPhraseInline(record) { + if (!record || record.kind !== "phrase") return; + const value = record.label || "?????"; + record.node.attr("content", + ``); + 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}`); + } +} diff --git a/static/index.html b/static/index.html index a46ca7a..8bfe559 100644 --- a/static/index.html +++ b/static/index.html @@ -196,10 +196,27 @@