import { CmapModel, ConceptMapConcept, ConceptMapConnector, ConceptMapPhrase, 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. * * cmap.js owns hit testing, dragging and render lifecycle callbacks. This file * adds the wiki-specific editor model and CMapTools-like controls. */ (() => { "use strict"; debug("cmap-racket-wiki.js loaded", { script: document.currentScript ? document.currentScript.src : null, cmapAvailable: true, stylesheets: Array.from(document.styleSheets || []) .map((sheet) => sheet.href) .filter((href) => href && href.includes("cmap.css")) }); ////////////////////////////////////////////////////////////////////////////// // Editor ////////////////////////////////////////////////////////////////////////////// /** * goal : Add the wiki editor model to the bundled CMap drawing component. * pre : canvas is a DOM element. * post : Concepts and linking phrases can be selected and connected by * direct manipulation. * result : A CmapEditor instance. */ class CmapEditor { constructor(canvas, options = {}) { this.canvas = canvas; this.renderItem = options.renderItem || null; this.onOpenPage = options.onOpenPage || null; this.onOpenSubMap = options.onOpenSubMap || null; this.onPopulateSubMap = options.onPopulateSubMap || null; this.onSubMapPromoted = options.onSubMapPromoted || null; this.onMapChange = options.onMapChange || null; this.onOpenCmap = options.onOpenCmap || null; this.onOpenParentCmap = options.onOpenParentCmap || null; this.onOpenExternalUrl = options.onOpenExternalUrl || null; this.onOpenStoredSubMap = options.onOpenStoredSubMap || null; this.onOpenBoundaryReference = options.onOpenBoundaryReference || null; this.onVisibilityChange = options.onVisibilityChange || null; this.onConfirmDetachFromSubmap = options.onConfirmDetachFromSubmap || null; this.onEditItem = options.onEditItem || null; this.onCreateConnectedItem = options.onCreateConnectedItem || null; this.onSelectionChange = options.onSelectionChange || null; this.onHistoryChange = options.onHistoryChange || null; this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null; this.labels = { createRelation: options.createRelationLabel || "Create relation", editConcept: options.editConceptLabel || "Edit concept", resizeConcept: options.resizeConceptLabel || "Resize concept", relation: options.relationLabel || "Relation" }; this.view = new CmapView( canvas, (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 = []; this.unresolvedConnectors = []; this.model = CmapModel.fromDocument({}); this.conceptMaps = this.model.conceptMap.conceptMapsById; this.documentMetadata = this.model.conceptMap.metadata; this.activeMapRoot = null; this.mapHistory = []; this.selectedItem = null; this.selectedItems = new Set(); this.selectedConnector = null; this.destroyed = false; this.nextId = 1; this.nextConnectorId = 1; this.nextGroupId = 1; this.dragRelation = null; this.zoomFactor = 1; this.marqueeMouseDownHandler = null; this.activeMarqueeCleanup = null; 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.interaction.installCanvasPanning(); this.interaction.installMarqueeSelection(); debug("editor created", { canvas: elementDescription(canvas), drawingEngine: this.map.constructor.name }); } /** Return the view record that renders one model item id. */ itemRecord(id) { return this.items.find((record) => Number(record.id) === Number(id)) || null; } /** Return a copy of the records currently rendered by this editor. */ itemRecords() { return [...this.items]; } /** Return rendered records that are visible in the active map context. */ visibleItemRecords() { return this.items.filter((record) => this.isEffectiveItemVisible(record)); } containsItemRecord(record) { return this.items.includes(record); } descendantItemRecords(record) { return this.items.filter((candidate) => this.isDescendantOf(candidate, record)); } setConceptMapReference(reference) { if (!reference?.id) throw new TypeError("A concept-map reference id is required"); this.conceptMaps.set(reference.id, reference); return reference; } itemCount() { return this.items.length; } connectorCount() { return this.connectors.length + this.unresolvedConnectors.length; } conceptRepository() { return this.model.repository; } conceptMapModel() { return this.model.conceptMap; } /** * Register the domain data behind a view record. * The model receives only serializable concept and placement fields. */ registerModelItem(record) { const existing = this.model.conceptMap.item(record.id); if (existing) return existing; const values = { kind: record.kind }; for (const field of PLACEMENT_FIELDS) { if (field === "parentSubmapId") { values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null; } else if (field === "hiddenContexts") { values.hiddenContexts = Array.from(record.hiddenContexts || []); } else if (field === "x" || field === "y" || field === "width" || field === "height") { values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]); } else { values[field] = record[field]; } } let modelItem; if (record.kind === "phrase") { modelItem = new ConceptMapPhrase(record.id, { ...values, label: record.label, synopsis: record.synopsis }); } else { const conceptValues = {}; for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field]; this.model.repository.ensure(record.conceptId, conceptValues); modelItem = new ConceptMapConcept(record.id, record.conceptId, values); } this.model.conceptMap.addItem(modelItem); return modelItem; } /** * Make an existing editor record a view onto its pure model item. * DOM handles stay on the record; all serializable fields live in the model. */ bindRecordToModel(record, modelItem) { if (record.modelItem === modelItem) return record; Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true }); Object.defineProperty(record, "kind", { configurable: true, get: () => modelItem.kind, set: (value) => { modelItem.kind = value; } }); Object.defineProperty(record, "conceptId", { configurable: true, get: () => modelItem.conceptId || null, set: (conceptId) => { if (modelItem instanceof ConceptMapPhrase || !conceptId) return; const current = this.model.repository.concept(modelItem.conceptId); const target = this.model.repository.concept(conceptId) || this.model.repository.ensure(conceptId, current ? current.toDocument() : {}); modelItem.conceptId = target.id; } }); for (const field of ["label", "synopsis"]) { Object.defineProperty(record, field, { configurable: true, get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] : this.model.repository.requireConcept(modelItem.conceptId)[field], set: (value) => { if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || ""); else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value }); } }); } for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) { Object.defineProperty(record, field, { configurable: true, get: () => modelItem instanceof ConceptMapPhrase ? null : this.model.repository.requireConcept(modelItem.conceptId)[field], set: (value) => { if (!(modelItem instanceof ConceptMapPhrase)) { this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value }); } } }); } for (const field of PLACEMENT_FIELDS.filter((name) => name !== "parentSubmapId" && name !== "x" && name !== "y")) { Object.defineProperty(record, field, { configurable: true, get: () => modelItem[field], set: (value) => { modelItem[field] = value; } }); } Object.defineProperty(record, "parentSubmap", { configurable: true, get: () => modelItem.parentSubmapId === null ? null : this.itemRecord(modelItem.parentSubmapId), set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; } }); return record; } /** Register and bind a record that was inserted through the editor view. */ attachRecordToModel(record) { return this.bindRecordToModel(record, this.registerModelItem(record)); } /** Bind a rendered connector to its map-local connector model. */ attachConnectorToModel(record) { let modelConnector = this.model.conceptMap.connector(record.id); if (!modelConnector) { modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector( record.id, record.source.id, record.target.id, record)); } Object.defineProperty(record, "modelConnector", { value: modelConnector, configurable: true }); for (const field of ["hasArrow", "lineColor", "lineWidth"]) { Object.defineProperty(record, field, { configurable: true, get: () => modelConnector[field], set: (value) => { modelConnector[field] = value; } }); } for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) { Object.defineProperty(record, field, { configurable: true, get: () => this.itemRecord(modelConnector[idField]), set: (value) => { modelConnector[idField] = Number(value.id); } }); } return record; } /** Keep the pure model synchronized with geometry owned by the drawing engine. */ synchronizeModel() { const itemIds = new Set(this.items.map((record) => Number(record.id))); for (const modelItem of this.model.conceptMap.items()) { if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id); } for (const record of this.items) { const modelItem = this.registerModelItem(record); this.bindRecordToModel(record, modelItem); if (record.node) { modelItem.x = Number(record.node.attr("x")); modelItem.y = Number(record.node.attr("y")); modelItem.width = Number(record.node.attr("width")); modelItem.height = Number(record.node.attr("height")); } } const connectorIds = new Set(); for (const connector of this.connectors) { connectorIds.add(Number(connector.id)); let modelConnector = this.model.conceptMap.connector(connector.id); if (!modelConnector) { modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector( connector.id, connector.source.id, connector.target.id, connector)); } modelConnector.sourceId = Number(connector.source.id); modelConnector.targetId = Number(connector.target.id); modelConnector.hasArrow = connector.hasArrow; modelConnector.lineColor = connector.lineColor; modelConnector.lineWidth = connector.lineWidth; } for (const connector of this.unresolvedConnectors) { connectorIds.add(Number(connector.id)); if (!this.model.conceptMap.connector(connector.id)) { this.model.conceptMap.addConnector(new ConceptMapConnector( connector.id, connector.sourceId, connector.targetId, connector)); } } for (const connector of this.model.conceptMap.connectors()) { if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id); } this.model.conceptMap.metadata = this.documentMetadata; this.model.conceptMap.setConceptMapReferences([...this.conceptMaps.values()]); this.conceptMaps = this.model.conceptMap.conceptMapsById; return this.model; } historySnapshot() { return JSON.stringify(this.toDocument()); } resetHistory() { this.history.reset(); } scheduleHistoryCommit() { this.history.scheduleCommit(); } refreshHistorySnapshot() { this.history.refreshSnapshot(); } commitHistory() { return this.history.commit(); } canUndo() { return this.history.canUndo(); } canRedo() { 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.submapAnchorLineElement) record.submapAnchorLineElement.remove(); record.node.remove(); } this.items = []; this.connectors = []; this.unresolvedConnectors = []; this.model = CmapModel.fromDocument({}); this.conceptMaps = this.model.conceptMap.conceptMapsById; this.documentMetadata = this.model.conceptMap.metadata; this.activeMapRoot = null; this.mapHistory = []; this.nextId = 1; this.nextConnectorId = 1; if (this.boundaryLayer) { this.boundaryLayer.remove(); this.boundaryLayer = null; } this.nextGroupId = 1; } restoreHistoryDocument(snapshot) { const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null; const mapHistoryIds = this.mapHistory.map((record) => record.id); 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(); } undo() { const restored = this.history.undo(); if (!restored) return false; debug("undo applied", { undoCount: this.history.undoCount, redoCount: this.history.redoCount }); return true; } redo() { const restored = this.history.redo(); if (!restored) return false; debug("redo applied", { undoCount: this.history.undoCount, redoCount: this.history.redoCount }); return true; } /** * goal : Add a draggable concept or linking-phrase node. * pre : options may contain the normal ionstage/cmap node attributes. * post : The item is drawn and receives selection/relation/resize UI. * result : The item record used by the editor. */ addItem(options = {}) { // Begin the transaction before map.node can synchronously render and fit // the item. A render callback may refresh the current layout snapshot; // while this timer is pending it must not replace the pre-add snapshot. this.scheduleHistoryCommit(); const requestedId = Number(options.id); const id = Number.isInteger(requestedId) && requestedId > 0 ? requestedId : this.nextId; this.nextId = Math.max(this.nextId, id + 1); const kind = options.kind || "concept"; const autoWidth = options.width === undefined || options.width === null; const autoHeight = options.height === undefined || options.height === null; const record = { id, conceptId: options.conceptId || (kind === "phrase" ? null : newConceptId()), kind, label: options.label || "Concept", synopsis: options.synopsis || "", aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [], tags: normalizeConceptTags(options.tags), descriptionPageSlug: kind === "phrase" ? null : (options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)), pageSlug: options.pageSlug || null, cmapSlug: options.cmapSlug || null, externalUrl: options.externalUrl || null, parentCmapLink: Boolean(options.parentCmapLink), groupId: options.groupId || null, childMap: options.childMap || null, parentSubmap: options.parentSubmap || null, submapDepth: numberOr(options.submapDepth, 0), expanded: false, submapInitialized: false, separateMap: Boolean(options.separateMap), mapReference: options.mapReference || null, hiddenContexts: new Set(Array.isArray(options.hiddenContexts) ? options.hiddenContexts.map(String) : []), layouts: options.layouts && typeof options.layouts === "object" ? Object.fromEntries(Object.entries(options.layouts).map(([context, layout]) => [context, { x: numberOr(Number(layout.x), numberOr(options.x, 0)), y: numberOr(Number(layout.y), numberOr(options.y, 0)), width: numberOr(Number(layout.width), numberOr(options.width, 220)), height: numberOr(Number(layout.height), numberOr(options.height, 70)), backgroundColor: layout.backgroundColor || options.backgroundColor || "#f3f6f8", borderColor: layout.borderColor || options.borderColor || "#5d6d7e", textColor: layout.textColor || options.textColor || "#222222", fontFamily: layout.fontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", fontSize: layout.fontSize || options.fontSize || "11pt", fontWeight: layout.fontWeight || options.fontWeight || "700", fontStyle: layout.fontStyle || options.fontStyle || "normal", synopsisTextColor: layout.synopsisTextColor || layout.textColor || options.synopsisTextColor || options.textColor || "#222222", synopsisFontFamily: layout.synopsisFontFamily || layout.fontFamily || options.synopsisFontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", synopsisFontSize: layout.synopsisFontSize || options.synopsisFontSize || "0.84em", synopsisFontWeight: layout.synopsisFontWeight || layout.fontWeight || options.synopsisFontWeight || options.fontWeight || "700", synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle || options.synopsisFontStyle || options.fontStyle || "normal" }])) : {}, submapAnchorLineElement: null, imageSource: options.imageSource || "", backgroundColor: options.backgroundColor || "#f3f6f8", borderColor: options.borderColor || "#5d6d7e", submapBackgroundColor: kind === "submap" ? (options.submapBackgroundColor || "#edf7e8") : null, submapBorderColor: kind === "submap" ? (options.submapBorderColor || "#57834a") : null, textColor: options.textColor || "#222222", fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif", fontSize: options.fontSize || "11pt", fontWeight: options.fontWeight || "700", fontStyle: options.fontStyle || "normal", synopsisTextColor: options.synopsisTextColor || options.textColor || "#222222", synopsisFontFamily: options.synopsisFontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", synopsisFontSize: options.synopsisFontSize || "0.84em", synopsisFontWeight: options.synopsisFontWeight || options.fontWeight || "700", synopsisFontStyle: options.synopsisFontStyle || options.fontStyle || "normal", width: options.width || (kind === "phrase" ? 145 : 220), height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)), autoWidth, autoHeight, fitContentPending: autoWidth || autoHeight, node: null }; record.usageCount = record.conceptId && kind !== "phrase" ? this.items.filter((item) => item.conceptId === record.conceptId && item.kind !== "phrase").length + 1 : null; const node = this.view.createNode({ content: this.itemHtml(record), contentType: "html", x: numberOr(options.x, 80 + ((id * 37) % 420)), y: numberOr(options.y, 80 + ((id * 83) % 360)), width: record.width, height: record.height, backgroundColor: record.backgroundColor, borderColor: record.borderColor, borderWidth: kind === "phrase" ? 0 : 2, textColor: record.textColor }); record.node = node; this.items.push(record); this.attachRecordToModel(record); this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []); this.refreshConceptMapReferences(); node.onRendered((_renderedNode, element) => this.decorateItem(record, element)); node.onMove((_movedNode, x, y) => this.handleItemMove(record, x, y)); node.onMoveEnd(() => this.handleItemMoveEnd(record)); debug("item registered; waiting for cmap render callback", { id: record.id, kind: record.kind, label: record.label }); return record; } refreshConceptUsageIndicators(conceptIds = null) { const ids = conceptIds ? new Set(conceptIds.filter(Boolean)) : new Set(this.items .map((item) => item.conceptId).filter(Boolean)); for (const conceptId of ids) { const peers = this.items.filter((item) => item.conceptId === conceptId && item.kind !== "phrase"); for (const peer of peers) { peer.usageCount = peers.length; if (!peer.node) continue; peer.node.attr({ content: this.itemHtml(peer) }); peer.node.redraw(); } } } addSubmapItem(parentSubmap, options = {}) { if (!parentSubmap || parentSubmap.kind !== "submap") { throw TypeError("A submap parent is required"); } const index = this.items.filter((item) => item.parentSubmap === parentSubmap).length; return this.addItem({ ...options, parentSubmap, submapDepth: parentSubmap.submapDepth + 1, x: options.x === undefined ? Number(parentSubmap.node.attr("x")) + 55 + ((index % 2) * 245) : options.x, y: options.y === undefined ? Number(parentSubmap.node.attr("y")) + 105 + (Math.floor(index / 2) * 125) : options.y }); } refreshConceptMapReferences() { for (const submap of this.items.filter((item) => item.separateMap && item.mapReference)) { submap.mapReference.itemIds = this.items .filter((item) => this.isDescendantOf(item, submap)) .map((item) => item.id); } } mapContextKey(root = this.activeMapRoot) { return this.submaps.mapContextKey(root); } itemLayout(record) { return { x: Number(record.node.attr("x")), y: Number(record.node.attr("y")), width: Number(record.node.attr("width")), height: Number(record.node.attr("height")), backgroundColor: record.backgroundColor, borderColor: record.borderColor, textColor: record.textColor, fontFamily: record.fontFamily, fontSize: record.fontSize, fontWeight: record.fontWeight, fontStyle: record.fontStyle, synopsisTextColor: record.synopsisTextColor, synopsisFontFamily: record.synopsisFontFamily, synopsisFontSize: record.synopsisFontSize, synopsisFontWeight: record.synopsisFontWeight, synopsisFontStyle: record.synopsisFontStyle }; } saveCurrentContextLayout() { const context = this.mapContextKey(); for (const record of this.items.filter((item) => this.isEffectiveItemVisible(item))) { record.layouts[context] = this.itemLayout(record); } } applyCurrentContextLayout() { const context = this.mapContextKey(); const visible = this.items.filter((item) => this.isEffectiveItemVisible(item)); let offset = { x: 0, y: 0 }; if (this.activeMapRoot && !this.activeMapRoot.layouts[context]) { const rootLayout = this.itemLayout(this.activeMapRoot); offset = { x: 80 - rootLayout.x, y: 80 - rootLayout.y }; } for (const record of visible) { if (!record.layouts[context]) { const current = this.itemLayout(record); record.layouts[context] = { ...current, x: current.x + offset.x, y: current.y + offset.y }; } const layout = record.layouts[context]; record.width = layout.width; record.height = layout.height; record.backgroundColor = layout.backgroundColor || record.backgroundColor; record.borderColor = layout.borderColor || record.borderColor; record.textColor = layout.textColor || record.textColor; record.fontFamily = layout.fontFamily || record.fontFamily; record.fontSize = layout.fontSize || record.fontSize; record.fontWeight = layout.fontWeight || record.fontWeight; record.fontStyle = layout.fontStyle || record.fontStyle; record.synopsisTextColor = layout.synopsisTextColor || layout.textColor || record.synopsisTextColor; record.synopsisFontFamily = layout.synopsisFontFamily || layout.fontFamily || record.synopsisFontFamily; record.synopsisFontSize = layout.synopsisFontSize || record.synopsisFontSize; record.synopsisFontWeight = layout.synopsisFontWeight || layout.fontWeight || record.synopsisFontWeight; record.synopsisFontStyle = layout.synopsisFontStyle || layout.fontStyle || record.synopsisFontStyle; record.node.attr({ x: layout.x, y: layout.y, width: layout.width, height: layout.height, content: this.itemHtml(record), backgroundColor: record.backgroundColor, borderColor: record.borderColor, textColor: record.textColor }); record.node.redraw(); } this.refreshSubmapVisibility(); this.refreshConnectorGeometry(); window.requestAnimationFrame(() => { 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(); } }); } refreshConnectorGeometry() { for (const connector of this.connectors) { const source = this.connectorEndpoint(connector.source); const target = this.connectorEndpoint(connector.target); this.applyConnectorVisualEndpoints(connector, source, target); if (source && target && source !== target) { if (connector.link.sourceNode() !== source.node) connector.link.sourceNode(source.node); if (connector.link.targetNode() !== target.node) connector.link.targetNode(target.node); connector.visualSource = source; connector.visualTarget = target; connector.link.straighten(); connector.link.redraw(); } } this.refreshBoundaryReferences(); } itemInsideActiveMap(record) { return this.submaps.itemInsideActiveMap(record); } 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.itemInsideActiveMap(neighbour) === inside) return neighbour; } return record; } refreshBoundaryReferences() { if (this.boundaryLayer) { this.boundaryLayer.remove(); this.boundaryLayer = null; } if (!this.activeMapRoot) return; const surface = this.surfaceElement(); if (!surface) return; const crossings = []; for (const connector of this.connectors) { const sourceInside = this.itemInsideActiveMap(connector.source); const targetInside = this.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.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.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.onOpenBoundaryReference) { this.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); } } isDescendantOf(record, submap) { return this.submaps.isDescendantOf(record, submap); } isItemVisible(record) { return this.submaps.isItemVisible(record); } hiddenItemsInCurrentContext() { return this.submaps.hiddenItemsInCurrentContext(); } canHideSelectionInCurrentContext() { return this.submaps.canHideSelectionInCurrentContext(); } hideSelectionInCurrentContext() { return this.submaps.hideSelectionInCurrentContext(); } showItemInCurrentContext(record) { return this.submaps.showItemInCurrentContext(record); } ensureSubmapContents(record) { return this.submaps.ensureSubmapContents(record); } toggleSubmap(record, expanded = !record.expanded) { return this.submaps.toggleSubmap(record, expanded); } openSubmapMap(record) { return this.submaps.openSubmapMap(record); } openRootMap() { return this.submaps.openRootMap(); } canStepBackWithinMap() { return this.submaps.canStepBackWithinMap(); } openParentMap() { return this.submaps.openParentMap(); } promoteSubmap(record, name) { return this.submaps.promoteSubmap(record, name); } prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) { return this.submaps.prepareStoredSubmapExtraction(record, targetSlug, childMetadata); } replaceDocument(document) { if (!document || typeof document !== "object") return false; this.history.replace(() => { this.clearDocument(); this.loadDocument(document); }); this.notifySelection(); return true; } /** Replace the editor contents with a public CMap domain model. */ replaceModel(model) { if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required"); return this.replaceDocument(model.toDocument()); } setZoom(percent) { const next = Math.max(25, Math.min(300, Number(percent) || 100)); this.zoomFactor = next / 100; this.view.setZoom(this.zoomFactor); this.ensureCanvasExtent(0, 0); debug("zoom changed", { percent: next, factor: this.zoomFactor }); return next; } zoomPercentage() { 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(); } ensureCanvasExtent(x, y, padding = 180) { const surface = this.surfaceElement(); if (!surface) return; const viewportWidth = this.canvas.clientWidth / this.zoomFactor; const viewportHeight = this.canvas.clientHeight / this.zoomFactor; const width = Math.max(viewportWidth, Number(x) + padding, Number.parseFloat(surface.style.minWidth) || 0); const height = Math.max(viewportHeight, Number(y) + padding, Number.parseFloat(surface.style.minHeight) || 0); surface.style.minWidth = `${Math.ceil(width)}px`; surface.style.minHeight = `${Math.ceil(height)}px`; } /** * goal : Change presentation/content of an existing item. * pre : record belongs to this editor. * post : The ionstage node and interaction handles are redrawn. */ updateItem(record, changes = {}) { // node.redraw may synchronously run fitItemToContent. Mark the mutation // first, so that automatic sizing cannot turn the edited state into the // history baseline before it has been committed as its own Undo step. this.scheduleHistoryCommit(); if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags); for (const [key, value] of Object.entries(changes)) { if (value !== undefined) record[key] = value; } const identityKeys = ["label", "synopsis", "aspects", "tags", "descriptionPageSlug", "pageSlug", "cmapSlug", "externalUrl", "imageSource"]; if (record.conceptId && record.kind !== "phrase") { for (const peer of this.items) { if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue; for (const key of identityKeys) { if (changes[key] !== undefined) peer[key] = changes[key]; } peer.node.attr({ content: this.itemHtml(peer) }); peer.node.redraw(); } } if (record.autoWidth || record.autoHeight) record.fitContentPending = true; record.width = numberOr(Number(record.width), record.node.attr("width")); record.height = numberOr(Number(record.height), record.node.attr("height")); record.node.attr({ content: this.itemHtml(record), width: record.width, height: record.height, backgroundColor: record.backgroundColor, borderColor: record.borderColor, textColor: record.textColor }); record.node.redraw(); if (record.kind === "submap") this.submaps.updateGroupAppearance(record); this.redrawConnectorsFor(record); this.refreshSubmapVisibility(); } /** * goal : Connect source to target with a separate linking phrase. * pre : source and target are items in this editor. * post : source -> phrase -> target is visible; the phrase can branch. * result : The newly created linking-phrase item. */ connectWithPhrase(source, target, label = "?????", editImmediately = true) { const a = this.itemCenter(source); const b = this.itemCenter(target); const parentSubmap = this.commonSubmapParent([source, target]); const phrase = this.addItem({ kind: "phrase", label, parentSubmap, submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, x: ((a.x + b.x) / 2) - 72, y: ((a.y + b.y) / 2) - 18, backgroundColor: "#fbfbf8", borderColor: "transparent" }); this.addConnector(source, phrase, false); this.addConnector(phrase, target, true); this.reconcilePhraseMembership(phrase); this.selectItem(phrase); if (editImmediately) this.editPhraseInline(phrase); return phrase; } submapChain(record) { const result = []; if (record.kind === "submap") result.push(record); let parent = record.parentSubmap; while (parent) { result.push(parent); parent = parent.parentSubmap; } return result; } commonSubmapParent(records) { if (!records.length) return null; const chains = records.map((record) => this.submapChain(record)); return chains[0] .filter((candidate) => chains.every((chain) => chain.includes(candidate))) .sort((a, b) => b.submapDepth - a.submapDepth)[0] || null; } reconcilePhraseMembership(phrase = null) { const phrases = phrase ? [phrase] : this.items.filter((item) => item.kind === "phrase"); for (const item of phrases) { const endpoints = this.connectors .filter((connector) => connector.source === item || connector.target === item) .map((connector) => connector.source === item ? connector.target : connector.source) .filter((endpoint) => endpoint.kind !== "phrase"); if (!endpoints.length) continue; const parent = this.commonSubmapParent(endpoints); item.parentSubmap = parent; item.submapDepth = parent ? parent.submapDepth + 1 : 0; } } /** * goal : Add one directed connector between two existing map items. * pre : source and target are items in this editor. * post : A selectable ionstage/cmap link joins them. * result : Connector record. */ addConnector(source, target, hasArrow = true, options = {}) { const sourceCenter = this.itemCenter(source); const targetCenter = this.itemCenter(target); const link = this.view.createConnector({ content: "", width: 1, height: 1, backgroundColor: "transparent", borderColor: "transparent", borderWidth: 0, lineColor: options.lineColor || "#333", lineWidth: numberOr(Number(options.lineWidth), 2), hasArrow, cx: (sourceCenter.x + targetCenter.x) / 2, cy: (sourceCenter.y + targetCenter.y) / 2, sourceX: sourceCenter.x, sourceY: sourceCenter.y, targetX: targetCenter.x, targetY: targetCenter.y }); link.sourceNode(source.node).targetNode(target.node); link.straighten(); link.draggable(true); const record = { id: Number.isInteger(Number(options.id)) && Number(options.id) > 0 ? Number(options.id) : this.nextConnectorId, link, source, target, visualSource: source, visualTarget: target, hasArrow, lineColor: options.lineColor || "#333", lineWidth: numberOr(Number(options.lineWidth), 2) }; this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1); this.attachConnectorToModel(record); this.connectors.push(record); link.onRendered((_renderedLink, element) => this.decorateConnector(record, element)); link.onConnectionChange((_changedLink, type, node) => this.handleConnectorConnectionChange(record, type, node)); link.visible(this.isItemVisible(source) && this.isItemVisible(target)); this.scheduleHistoryCommit(); return record; } /** * Keep a dragged ionstage/cmap link endpoint and the persisted wiki * connector model in lockstep. The drawing library owns the endpoint * handles; the wiki model owns sourceId/targetId and submap projection. */ handleConnectorConnectionChange(connector, type, node) { if (!connector || (type !== "source" && type !== "target")) return false; const endpoint = this.items.find((item) => item.node === node) || null; const otherType = type === "source" ? "target" : "source"; // A connector is only a storable relation while both ends are attached // to different items. Restore the previous logical projection when an // endpoint is dropped on empty space or on its opposite endpoint. if (!endpoint || endpoint === connector[otherType]) { connector[`visual${type === "source" ? "Source" : "Target"}`] = null; this.applyConnectorVisualEndpoints(connector, this.connectorEndpoint(connector.source), this.connectorEndpoint(connector.target)); return false; } if (connector[type] === endpoint) { connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint; return false; } const previous = connector[type]; connector[type] = endpoint; connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint; this.reconcilePhraseMembership(); this.refreshSubmapVisibility(); this.refreshConnectorGeometry(); debug("connector endpoint changed", { connectorId: connector.id, type, previousItemId: previous ? previous.id : null, itemId: endpoint.id }); 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 { tags: [...this.documentMetadata.tags], summary: this.documentMetadata.summary, explanationPageSlug: this.documentMetadata.explanationPageSlug }; } setDocumentMetadata(metadata = {}) { this.scheduleHistoryCommit(); this.documentMetadata = { tags: Array.isArray(metadata.tags) ? metadata.tags.map(String) .map((tag) => tag.trim()).filter(Boolean) : [], summary: String(metadata.summary || "").trim(), explanationPageSlug: String(metadata.explanationPageSlug || "").trim() }; this.model.conceptMap.metadata = this.documentMetadata; return this.getDocumentMetadata(); } /** Serialize the pure repository and map model at the API boundary. */ toDocument() { this.saveCurrentContextLayout(); this.refreshConceptMapReferences(); return this.synchronizeModel().toDocument(); } /** Return the synchronized domain model currently edited by this view. */ currentModel() { this.saveCurrentContextLayout(); this.refreshConceptMapReferences(); return this.synchronizeModel(); } /** Load a public CMap domain model into an empty editor. */ loadModel(model) { if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required"); this.loadDocument(model.toDocument()); } loadDocument(document = {}) { if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) { throw new Error("A concept map document can only be loaded into an empty editor"); } this.model = CmapModel.fromDocument(document); this.conceptMaps = this.model.conceptMap.conceptMapsById; this.documentMetadata = this.model.conceptMap.metadata; const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument()); const connectorDocuments = this.model.conceptMap.connectors() .map((connector) => connector.toDocument()); const records = new Map(); for (const itemDocument of itemDocuments) { const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {}; const record = this.addItem({ ...itemDocument, ...concept, id: itemDocument.id, conceptId: itemDocument.conceptId, kind: itemDocument.kind || concept.kind || "concept", parentSubmap: null }); record.autoWidth = Boolean(itemDocument.autoWidth); record.autoHeight = Boolean(itemDocument.autoHeight); record.fitContentPending = false; record.expanded = Boolean(itemDocument.expanded); record.submapInitialized = Boolean(itemDocument.submapInitialized); records.set(record.id, record); } const groupNumbers = itemDocuments .map((item) => /^group-(\d+)$/.exec(item.groupId || "")) .filter(Boolean) .map((match) => Number(match[1])); this.nextGroupId = groupNumbers.length ? Math.max(...groupNumbers) + 1 : 1; for (const itemDocument of itemDocuments) { const record = records.get(Number(itemDocument.id)); const parent = records.get(Number(itemDocument.parentSubmapId)) || null; if (record) record.parentSubmap = parent; } for (const connectorDocument of connectorDocuments) { const source = records.get(Number(connectorDocument.sourceId)); const target = records.get(Number(connectorDocument.targetId)); if (!source || !target) { this.unresolvedConnectors.push({ ...connectorDocument }); const unresolvedId = Number(connectorDocument.id); if (Number.isInteger(unresolvedId) && unresolvedId > 0) { this.nextConnectorId = Math.max(this.nextConnectorId, unresolvedId + 1); } console.warn(`${debugPrefix} relation retained without rendering`, { relationId: connectorDocument.id || null, sourceId: connectorDocument.sourceId, targetId: connectorDocument.targetId, missingSource: !source, missingTarget: !target }); continue; } this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument); } this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.applyCurrentContextLayout(); this.clearSelection(); if (!this.history.isRestoring) this.resetHistory(); return this; } // 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; if (record.kind === "phrase") { this.editPhraseInline(record); } else if (this.onEditItem) { this.onEditItem(record); } return true; } 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; for (const child of this.items.filter((item) => item.parentSubmap === record)) { this.updateSubmapDepth(child, depth + 1); } } pointInBounds(point, bounds) { return Boolean(bounds && point.x >= bounds.left && point.x <= bounds.right && point.y >= bounds.top && point.y <= bounds.bottom); } pointNearConnector(point, tolerance = 8 / this.zoomFactor) { const distanceToSegment = (start, end) => { const segmentX = end.x - start.x; const segmentY = end.y - start.y; const segmentLengthSquared = (segmentX * segmentX) + (segmentY * segmentY); if (segmentLengthSquared === 0) { return Math.hypot(point.x - start.x, point.y - start.y); } const projection = Math.max(0, Math.min(1, (((point.x - start.x) * segmentX) + ((point.y - start.y) * segmentY)) / segmentLengthSquared)); const nearestX = start.x + (projection * segmentX); const nearestY = start.y + (projection * segmentY); return Math.hypot(point.x - nearestX, point.y - nearestY); }; return this.connectors.some((connector) => { const source = connector.visualSource || this.connectorEndpoint(connector.source); const target = connector.visualTarget || this.connectorEndpoint(connector.target); if (!source || !target || source === target) return false; return distanceToSegment(this.itemCenter(source), this.itemCenter(target)) <= tolerance; }); } submapAtPoint(point, excludedRecord = null, excludedRecords = []) { const exclusions = new Set(excludedRecords); const candidates = this.items .filter((item) => item.kind === "submap" && item.expanded && item !== excludedRecord && !exclusions.has(item) && (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && !excludedRecords.some((record) => this.isDescendantOf(item, record)) && this.isItemVisible(item)) .sort((a, b) => b.submapDepth - a.submapDepth); const match = candidates.find((item) => this.pointInBounds(point, this.submapBounds(item, excludedRecord))); return match || (this.activeMapRoot && this.activeMapRoot !== excludedRecord && !exclusions.has(this.activeMapRoot) ? this.activeMapRoot : null); } submapBounds(record, excludedRecord = null) { const visibleItems = this.items.filter((item) => this.isDescendantOf(item, record) && item !== excludedRecord && (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && this.isItemVisible(item)); if (visibleItems.length === 0) return null; return { left: Math.min(...visibleItems.map((item) => Number(item.node.attr("x")))) - 34, top: Math.min(...visibleItems.map((item) => Number(item.node.attr("y")))) - 42, right: Math.max(...visibleItems.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 34, bottom: Math.max(...visibleItems.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 34 }; } visualEndpointFor(record) { if (this.isItemVisible(record)) return record; if (record.kind === "phrase" || this.activeMapRoot) return null; let parent = record.parentSubmap; while (parent) { if (this.isItemVisible(parent)) return parent; parent = parent.parentSubmap; } return null; } isEffectiveItemVisible(record) { if (!this.isItemVisible(record)) return false; if (record.kind !== "phrase") return true; const neighbours = this.connectors .filter((connector) => connector.source === record || connector.target === record) .map((connector) => connector.source === record ? connector.target : connector.source) .filter((item) => item.kind !== "phrase"); return neighbours.every((item) => Boolean(this.visualEndpointFor(item))); } connectorEndpoint(record) { if (record.kind === "phrase") { return this.isEffectiveItemVisible(record) ? record : null; } return this.visualEndpointFor(record); } applyConnectorVisualEndpoints(connector, source, target) { if (!source || !target || source === target) { connector.link.visible(false); return; } if (connector.visualSource !== source) { connector.link.sourceNode(source.node); connector.visualSource = source; } if (connector.visualTarget !== target) { connector.link.targetNode(target.node); connector.visualTarget = target; } connector.link.visible(true); connector.link.straighten(); connector.link.redraw(); } refreshSubmapVisibility() { this.submaps.refreshVisibility(); } updateSubmapFrame(record) { this.submaps.refreshGroups(); return this.submaps.diagramGroups.get(record)?.redraw() || null; } redrawConnectorsFor(record) { for (const connector of this.connectors) { if (connector.source === record || connector.target === record || connector.visualSource === record || connector.visualTarget === record) { connector.link.straighten(); connector.link.redraw(); } } } destroy() { this.destroyed = true; this.history.destroy(); if (this.boundaryScrollHandler) { this.canvas.removeEventListener("scroll", this.boundaryScrollHandler); this.boundaryScrollHandler = null; } this.interaction.destroy(); for (const item of this.items) { if (item.submapAnchorLineElement) { item.submapAnchorLineElement.remove(); item.submapAnchorLineElement = null; } } this.view.destroy(); debug("editor destroyed"); } deleteSelection() { const records = new Set(this.selectedAll().filter((item) => item !== this.activeMapRoot)); for (const record of Array.from(records)) { if (record.kind === "submap") { for (const item of this.items) { if (this.isDescendantOf(item, record)) records.add(item); } } } const connectors = new Set(this.connectors.filter((connector) => connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target))); 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.model.conceptMap.removeConnector(connector.id); } this.connectors = this.connectors.filter((connector) => !connectors.has(connector)); for (const record of records) { if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id); record.node.remove(); this.model.conceptMap.removeItem(record.id); } if (records.size && this.unresolvedConnectors.length) { const deletedIds = new Set(Array.from(records).map((record) => Number(record.id))); this.unresolvedConnectors = this.unresolvedConnectors.filter((connector) => !deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId))); } this.items = this.items.filter((item) => !records.has(item)); this.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId)); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.refreshSubmapVisibility(); this.notifySelection(); debug("selection deleted", { itemIds: Array.from(records).map((item) => item.id), connectorIds: Array.from(connectors).map((connector) => connector.id) }); this.scheduleHistoryCommit(); return true; } logicalCanvasWidth() { const surface = this.surfaceElement(); return Math.max(this.canvas.clientWidth / this.zoomFactor, surface ? surface.scrollWidth : 0); } logicalCanvasHeight() { const surface = this.surfaceElement(); return Math.max(this.canvas.clientHeight / this.zoomFactor, surface ? surface.scrollHeight : 0); } canvasPoint(event) { const rect = this.canvas.getBoundingClientRect(); return { x: (event.clientX - rect.left + this.canvas.scrollLeft) / this.zoomFactor, y: (event.clientY - rect.top + this.canvas.scrollTop) / this.zoomFactor }; } itemAt(clientX, clientY) { const element = document.elementFromPoint(clientX, clientY); const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null; if (!itemElement) return null; const id = Number(itemElement.dataset.rwCmapItemId); return this.items.find((item) => item.id === id) || null; } itemCenter(record) { return { x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2), y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2) }; } itemHtml(record) { if (record.kind === "phrase") { return `
${escapeHtml(record.label || "?????")}
`; } if (this.renderItem) return this.renderItem(record); return `
${escapeHtml(record.label)}
`; } notifySelection() { if (this.onSelectionChange) { this.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll()); } } } let lastEditor = null; window.RacketWikiCmap = { version: "0.2.122", createEditor(canvas, options) { lastEditor = new CmapEditor(canvas, options); return lastEditor; }, debugSelection() { if (!lastEditor) { debug("debugSelection: no editor has been created"); return null; } const record = lastEditor.selected(); const element = record ? record.node.element() : null; const result = { selectedId: record ? record.id : null, selectedIds: lastEditor.selectedAll().map((item) => item.id), selectedKind: record ? record.kind : null, selectedLabel: record ? record.label : null, element: elementDescription(element), selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")), handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0, computedStyle: selectionStyle(element) }; debug("manual selection inspection", result); return result; } }; })();