/* * Racket Wiki editor layer for the bundled racket-wiki CMap component. * * cmap.js owns hit testing, dragging and render lifecycle callbacks. This file * adds the wiki-specific editor model and CMapTools-like controls. */ (() => { "use strict"; const debugPrefix = "[racket-wiki:cmap 0.2.84]"; function debug(message, details) { if (details === undefined) { console.info(debugPrefix, message); return; } console.info(debugPrefix, message, details); } function elementDescription(element) { if (!(element instanceof Element)) return String(element); return { tag: element.tagName, id: element.id || null, classes: Array.from(element.classList), itemId: element.dataset.rwCmapItemId || null }; } function selectionStyle(element) { if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null; const style = window.getComputedStyle(element); return { pointerEvents: style.pointerEvents, outline: style.outline, outlineOffset: style.outlineOffset, boxShadow: style.boxShadow, overflow: style.overflow, zIndex: style.zIndex }; } debug("cmap-racket-wiki.js loaded", { script: document.currentScript ? document.currentScript.src : null, cmapAvailable: typeof window.Cmap === "function", stylesheets: Array.from(document.styleSheets || []) .map((sheet) => sheet.href) .filter((href) => href && href.includes("cmap.css")) }); ////////////////////////////////////////////////////////////////////////////// // Small helpers ////////////////////////////////////////////////////////////////////////////// function escapeHtml(value) { return String(value || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function numberOr(value, fallback) { return Number.isFinite(value) ? value : fallback; } ////////////////////////////////////////////////////////////////////////////// // Editor ////////////////////////////////////////////////////////////////////////////// /** * goal : Add the wiki editor model to the bundled CMap drawing component. * pre : canvas is a DOM element and window.Cmap is available. * post : Concepts and linking phrases can be selected and connected by * direct manipulation. * result : A CmapEditor instance. */ class CmapEditor { constructor(canvas, options = {}) { this.canvas = canvas; this.CmapFactory = options.Cmap || window.Cmap; this.renderItem = options.renderItem || null; this.onOpenPage = options.onOpenPage || null; this.onOpenSubMap = options.onOpenSubMap || null; this.onPopulateSubMap = options.onPopulateSubMap || null; this.onSubMapPromoted = options.onSubMapPromoted || null; this.onMapChange = options.onMapChange || null; this.onOpenCmap = options.onOpenCmap || null; this.onConfirmDetachFromSubmap = options.onConfirmDetachFromSubmap || null; this.onEditItem = options.onEditItem || null; this.onCreateConnectedItem = options.onCreateConnectedItem || null; this.onSelectionChange = options.onSelectionChange || null; this.onHistoryChange = options.onHistoryChange || null; this.labels = { createRelation: options.createRelationLabel || "Create relation", editConcept: options.editConceptLabel || "Edit concept", resizeConcept: options.resizeConceptLabel || "Resize concept", relation: options.relationLabel || "Relation" }; this.map = this.CmapFactory(canvas); this.map.onSelection((component, event) => this.handleMapSelection(component, event)); this.map.onActivation((component, event) => this.handleMapActivation(component, event)); this.items = []; this.connectors = []; this.conceptMaps = new Map(); this.activeMapRoot = null; this.mapHistory = []; this.selectedItem = null; this.selectedItems = new Set(); this.selectedConnector = null; this.nextId = 1; this.nextConnectorId = 1; this.nextGroupId = 1; this.dragRelation = null; this.zoomFactor = 1; this.marqueeMouseDownHandler = null; this.activeMarqueeCleanup = null; this.undoStack = []; this.redoStack = []; this.historySnapshotValue = null; this.historyTimer = null; this.historyReady = false; this.historyRestoring = false; this.historyLimit = 100; this.installMarqueeSelection(); debug("editor created", { canvas: elementDescription(canvas), cmapFactoryAvailable: typeof this.CmapFactory === "function" }); } historySnapshot() { return JSON.stringify(this.toDocument()); } notifyHistory() { if (this.onHistoryChange) { this.onHistoryChange({ canUndo: this.canUndo(), canRedo: this.canRedo() }); } } resetHistory() { if (this.historyTimer !== null) { window.clearTimeout(this.historyTimer); this.historyTimer = null; } this.undoStack = []; this.redoStack = []; this.historyReady = true; this.historySnapshotValue = this.historySnapshot(); this.notifyHistory(); } scheduleHistoryCommit() { if (!this.historyReady || this.historyRestoring) return; if (this.historyTimer !== null) window.clearTimeout(this.historyTimer); this.historyTimer = window.setTimeout(() => { this.historyTimer = null; this.commitHistory(); }, 0); } refreshHistorySnapshot() { if (!this.historyReady || this.historyRestoring || this.historyTimer !== null) return; this.historySnapshotValue = this.historySnapshot(); } commitHistory() { if (!this.historyReady || this.historyRestoring) return false; if (this.historyTimer !== null) { window.clearTimeout(this.historyTimer); this.historyTimer = null; } const nextSnapshot = this.historySnapshot(); if (nextSnapshot === this.historySnapshotValue) return false; if (this.historySnapshotValue !== null) { this.undoStack.push(this.historySnapshotValue); if (this.undoStack.length > this.historyLimit) this.undoStack.shift(); } this.historySnapshotValue = nextSnapshot; this.redoStack = []; this.notifyHistory(); return true; } canUndo() { return this.undoStack.length > 0; } canRedo() { return this.redoStack.length > 0; } clearDocument() { this.clearSelection(false); for (const connector of this.connectors) connector.link.remove(); for (const record of this.items) { if (record.submapFrameElement) record.submapFrameElement.remove(); record.node.remove(); } this.items = []; this.connectors = []; this.conceptMaps = new Map(); this.activeMapRoot = null; this.mapHistory = []; this.nextId = 1; this.nextConnectorId = 1; this.nextGroupId = 1; } restoreHistorySnapshot(snapshot) { const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null; const mapHistoryIds = this.mapHistory.map((record) => record.id); this.historyRestoring = true; try { this.clearDocument(); this.loadDocument(JSON.parse(snapshot)); this.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null; this.mapHistory = mapHistoryIds .map((id) => this.items.find((item) => item.id === id)) .filter(Boolean); this.refreshSubmapVisibility(); const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); } finally { this.historyRestoring = false; } this.historySnapshotValue = snapshot; this.notifySelection(); this.notifyHistory(); } undo() { this.commitHistory(); if (!this.canUndo()) return false; this.redoStack.push(this.historySnapshotValue); const snapshot = this.undoStack.pop(); this.restoreHistorySnapshot(snapshot); debug("undo applied", { undoCount: this.undoStack.length, redoCount: this.redoStack.length }); return true; } redo() { this.commitHistory(); if (!this.canRedo()) return false; this.undoStack.push(this.historySnapshotValue); const snapshot = this.redoStack.pop(); this.restoreHistorySnapshot(snapshot); debug("redo applied", { undoCount: this.undoStack.length, redoCount: this.redoStack.length }); return true; } /** * goal : Add a draggable concept or linking-phrase node. * pre : options may contain the normal ionstage/cmap node attributes. * post : The item is drawn and receives selection/relation/resize UI. * result : The item record used by the editor. */ addItem(options = {}) { // Begin the transaction before map.node can synchronously render and fit // the item. A render callback may refresh the current layout snapshot; // while this timer is pending it must not replace the pre-add snapshot. this.scheduleHistoryCommit(); const requestedId = Number(options.id); const id = Number.isInteger(requestedId) && requestedId > 0 ? requestedId : this.nextId; this.nextId = Math.max(this.nextId, id + 1); const kind = options.kind || "concept"; const autoWidth = options.width === undefined || options.width === null; const autoHeight = options.height === undefined || options.height === null; const record = { id, kind, label: options.label || "Concept", synopsis: options.synopsis || "", pageSlug: options.pageSlug || null, cmapSlug: options.cmapSlug || null, parentCmapLink: Boolean(options.parentCmapLink), groupId: options.groupId || null, childMap: options.childMap || null, parentSubmap: options.parentSubmap || null, submapDepth: numberOr(options.submapDepth, 0), expanded: false, submapInitialized: false, separateMap: Boolean(options.separateMap), mapReference: options.mapReference || null, submapFrameElement: null, imageSource: options.imageSource || "", backgroundColor: options.backgroundColor || "#f3f6f8", borderColor: options.borderColor || "#5d6d7e", submapBackgroundColor: kind === "submap" ? (options.submapBackgroundColor || "#edf7e8") : null, submapBorderColor: kind === "submap" ? (options.submapBorderColor || "#57834a") : null, textColor: options.textColor || "#222222", fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif", fontSize: options.fontSize || "11pt", width: options.width || (kind === "phrase" ? 145 : 220), height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)), autoWidth, autoHeight, fitContentPending: autoWidth || autoHeight, node: null }; const node = this.map.node({ content: this.itemHtml(record), contentType: "html", x: numberOr(options.x, 80 + ((id * 37) % 420)), y: numberOr(options.y, 80 + ((id * 83) % 360)), width: record.width, height: record.height, backgroundColor: record.backgroundColor, borderColor: record.borderColor, borderWidth: kind === "phrase" ? 0 : 2, textColor: record.textColor }); record.node = node; this.items.push(record); this.refreshConceptMapReferences(); node.onRendered((_renderedNode, element) => this.decorateItem(record, element)); node.onMove((_movedNode, x, y) => this.handleItemMove(record, x, y)); node.onMoveEnd(() => this.handleItemMoveEnd(record)); debug("item registered; waiting for cmap render callback", { id: record.id, kind: record.kind, label: record.label }); return record; } addSubmapItem(parentSubmap, options = {}) { if (!parentSubmap || parentSubmap.kind !== "submap") { throw TypeError("A submap parent is required"); } const index = this.items.filter((item) => item.parentSubmap === parentSubmap).length; return this.addItem({ ...options, parentSubmap, submapDepth: parentSubmap.submapDepth + 1, x: options.x === undefined ? Number(parentSubmap.node.attr("x")) + 55 + ((index % 2) * 245) : options.x, y: options.y === undefined ? Number(parentSubmap.node.attr("y")) + 105 + (Math.floor(index / 2) * 125) : options.y }); } refreshConceptMapReferences() { for (const submap of this.items.filter((item) => item.separateMap && item.mapReference)) { submap.mapReference.itemIds = this.items .filter((item) => this.isDescendantOf(item, submap)) .map((item) => item.id); } } isDescendantOf(record, submap) { let parent = record.parentSubmap; while (parent) { if (parent === submap) return true; parent = parent.parentSubmap; } return false; } isItemVisible(record) { if (this.activeMapRoot) { if (record === this.activeMapRoot) return true; if (!this.isDescendantOf(record, this.activeMapRoot)) return false; let parent = record.parentSubmap; while (parent && parent !== this.activeMapRoot) { if (!parent.expanded) return false; parent = parent.parentSubmap; } return parent === this.activeMapRoot; } let parent = record.parentSubmap; while (parent) { if (!parent.expanded) return false; parent = parent.parentSubmap; } return true; } ensureSubmapContents(record) { if (record.submapInitialized) return; record.submapInitialized = true; if (this.onPopulateSubMap) this.onPopulateSubMap(record, this); } toggleSubmap(record, expanded = !record.expanded) { if (!record || record.kind !== "submap") return false; if (record.separateMap) { record.expanded = false; if (record === this.activeMapRoot) { this.refreshSubmapVisibility(); return false; } return this.openSubmapMap(record); } if (expanded) this.ensureSubmapContents(record); record.expanded = Boolean(expanded); this.refreshSubmapVisibility(); const element = record.node.element(); if (element) this.ensureSubmapToggle(record, element); debug("submap toggled", { id: record.id, expanded: record.expanded, childCount: this.items.filter((item) => item.parentSubmap === record).length }); if (this.onOpenSubMap) this.onOpenSubMap(record, record.expanded); this.scheduleHistoryCommit(); return record.expanded; } openSubmapMap(record) { if (!record || record.kind !== "submap" || !record.separateMap) return false; if (record === this.activeMapRoot) return true; this.ensureSubmapContents(record); this.clearSelection(); if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot); this.activeMapRoot = record; this.refreshSubmapVisibility(); if (this.onMapChange) this.onMapChange(record.mapReference, record); debug("separate concept map opened", { id: record.id, mapReference: record.mapReference }); return true; } openRootMap() { if (!this.activeMapRoot) return false; this.clearSelection(); this.activeMapRoot = null; this.mapHistory = []; this.refreshSubmapVisibility(); if (this.onMapChange) this.onMapChange(null, null); debug("root concept map opened"); return true; } openParentMap() { if (!this.activeMapRoot) return false; this.clearSelection(); this.activeMapRoot = this.mapHistory.pop() || null; this.refreshSubmapVisibility(); const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); debug("parent concept map opened", { id: this.activeMapRoot ? this.activeMapRoot.id : null, mapReference: reference }); return true; } promoteSubmap(record, name) { if (!record || record.kind !== "submap") return null; this.ensureSubmapContents(record); record.childMap = String(name || record.label).trim() || record.label; record.separateMap = true; record.mapReference = { id: `cmap-${record.id}`, title: record.childMap, rootItemId: record.id, itemIds: this.items .filter((item) => this.isDescendantOf(item, record)) .map((item) => item.id) }; this.conceptMaps.set(record.mapReference.id, record.mapReference); record.expanded = false; this.updateItem(record, { synopsis: `Concept map: ${record.childMap}` }); if (this.onSubMapPromoted) this.onSubMapPromoted(record); this.refreshSubmapVisibility(); debug("submap promoted to separate map", { id: record.id, childMap: record.childMap }); return record.mapReference; } setZoom(percent) { const next = Math.max(25, Math.min(300, Number(percent) || 100)); this.zoomFactor = next / 100; this.map.zoom(this.zoomFactor); this.ensureCanvasExtent(0, 0); debug("zoom changed", { percent: next, factor: this.zoomFactor }); return next; } zoomPercentage() { return Math.round(this.zoomFactor * 100); } surfaceElement() { return this.canvas.querySelector(":scope > .rw-cmap-surface"); } ensureCanvasExtent(x, y, padding = 180) { const surface = this.surfaceElement(); if (!surface) return; const viewportWidth = this.canvas.clientWidth / this.zoomFactor; const viewportHeight = this.canvas.clientHeight / this.zoomFactor; const width = Math.max(viewportWidth, Number(x) + padding, Number.parseFloat(surface.style.minWidth) || 0); const height = Math.max(viewportHeight, Number(y) + padding, Number.parseFloat(surface.style.minHeight) || 0); surface.style.minWidth = `${Math.ceil(width)}px`; surface.style.minHeight = `${Math.ceil(height)}px`; } /** * goal : Change presentation/content of an existing item. * pre : record belongs to this editor. * post : The ionstage node and interaction handles are redrawn. */ updateItem(record, changes = {}) { // node.redraw may synchronously run fitItemToContent. Mark the mutation // first, so that automatic sizing cannot turn the edited state into the // history baseline before it has been committed as its own Undo step. this.scheduleHistoryCommit(); for (const [key, value] of Object.entries(changes)) { if (value !== undefined) record[key] = value; } if (record.autoWidth || record.autoHeight) record.fitContentPending = true; record.width = numberOr(Number(record.width), record.node.attr("width")); record.height = numberOr(Number(record.height), record.node.attr("height")); record.node.attr({ content: this.itemHtml(record), width: record.width, height: record.height, backgroundColor: record.backgroundColor, borderColor: record.borderColor, textColor: record.textColor }); record.node.redraw(); this.redrawConnectorsFor(record); this.refreshSubmapVisibility(); } /** * goal : Connect source to target with a separate linking phrase. * pre : source and target are items in this editor. * post : source -> phrase -> target is visible; the phrase can branch. * result : The newly created linking-phrase item. */ connectWithPhrase(source, target, label = "?????", editImmediately = true) { const a = this.itemCenter(source); const b = this.itemCenter(target); const parentSubmap = this.commonSubmapParent([source, target]); const phrase = this.addItem({ kind: "phrase", label, parentSubmap, submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, x: ((a.x + b.x) / 2) - 72, y: ((a.y + b.y) / 2) - 18, backgroundColor: "#fbfbf8", borderColor: "transparent" }); this.addConnector(source, phrase, false); this.addConnector(phrase, target, true); this.reconcilePhraseMembership(phrase); this.selectItem(phrase); if (editImmediately) this.editPhraseInline(phrase); return phrase; } submapChain(record) { const result = []; if (record.kind === "submap") result.push(record); let parent = record.parentSubmap; while (parent) { result.push(parent); parent = parent.parentSubmap; } return result; } commonSubmapParent(records) { if (!records.length) return null; const chains = records.map((record) => this.submapChain(record)); return chains[0] .filter((candidate) => chains.every((chain) => chain.includes(candidate))) .sort((a, b) => b.submapDepth - a.submapDepth)[0] || null; } reconcilePhraseMembership(phrase = null) { const phrases = phrase ? [phrase] : this.items.filter((item) => item.kind === "phrase"); for (const item of phrases) { const endpoints = this.connectors .filter((connector) => connector.source === item || connector.target === item) .map((connector) => connector.source === item ? connector.target : connector.source) .filter((endpoint) => endpoint.kind !== "phrase"); if (!endpoints.length) continue; const parent = this.commonSubmapParent(endpoints); item.parentSubmap = parent; item.submapDepth = parent ? parent.submapDepth + 1 : 0; } } /** * goal : Add one directed connector between two existing map items. * pre : source and target are items in this editor. * post : A selectable ionstage/cmap link joins them. * result : Connector record. */ addConnector(source, target, hasArrow = true, options = {}) { const sourceCenter = this.itemCenter(source); const targetCenter = this.itemCenter(target); const link = this.map.link({ content: "", width: 1, height: 1, backgroundColor: "transparent", borderColor: "transparent", borderWidth: 0, lineColor: options.lineColor || "#333", lineWidth: numberOr(Number(options.lineWidth), 2), hasArrow, cx: (sourceCenter.x + targetCenter.x) / 2, cy: (sourceCenter.y + targetCenter.y) / 2, sourceX: sourceCenter.x, sourceY: sourceCenter.y, targetX: targetCenter.x, targetY: targetCenter.y }); link.sourceNode(source.node).targetNode(target.node); link.straighten(); link.draggable(true); const record = { id: Number.isInteger(Number(options.id)) && Number(options.id) > 0 ? Number(options.id) : this.nextConnectorId, link, source, target, visualSource: source, visualTarget: target, hasArrow, lineColor: options.lineColor || "#333", lineWidth: numberOr(Number(options.lineWidth), 2) }; this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1); this.connectors.push(record); link.onRendered((_renderedLink, element) => this.decorateConnector(record, element)); link.visible(this.isItemVisible(source) && this.isItemVisible(target)); this.scheduleHistoryCommit(); return record; } /** * goal : Select a concept/linking phrase, optionally beside the current selection. * pre : record belongs to this editor. * post : Its logical group is selected as one unit; the primary item exposes handles. */ selectItem(record, options = {}) { if (!record) { this.clearSelection(); return; } const additive = Boolean(options.additive); const toggle = Boolean(options.toggle); const groupRecords = record.groupId && options.expandGroup !== false ? this.items.filter((item) => item.groupId === record.groupId && this.isItemVisible(item)) : [record]; debug("selectItem called", { requestedId: record.id, requestedKind: record.kind, additive, groupId: record.groupId, previousIds: this.selectedAll().map((item) => item.id) }); if (!additive) this.clearSelection(false); const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item)); for (const item of groupRecords) { if (remove) { this.selectedItems.delete(item); } else { this.selectedItems.add(item); } } this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record; this.selectedConnector = null; this.refreshSelectionDecoration(); debug("selection applied", { selectedId: this.selectedItem ? this.selectedItem.id : null, selectedIds: this.selectedAll().map((item) => item.id), selectionCount: this.selectedItems.size }); this.notifySelection(); } refreshSelectionDecoration() { for (const item of this.items) { const element = item.node.element(); const selected = this.selectedItems.has(item); if (element) { element.classList.toggle("rw-cmap-selected", selected); if (selected) { element.setAttribute("aria-selected", "true"); item.node.toFront(); } else { element.removeAttribute("aria-selected"); } this.removeHandles(element); if (selected && item === this.selectedItem) this.ensureHandles(item, element); } if (item.kind === "submap") this.updateSubmapFrame(item); } } /** * goal : Select a connector so its line becomes clearly visible. * pre : record belongs to this editor. * post : Previous selection is cleared and the connector is highlighted. */ selectConnector(record) { this.clearSelection(); this.selectedConnector = record; record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 }); record.link.redraw(); this.notifySelection(); } /** * goal : Remove the current item/connector selection. * post : No item handles or connector highlight remain. */ clearSelection(notify = true) { const clearedItemIds = this.selectedAll().map((item) => item.id); const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null; for (const item of this.selectedItems) { if (item.submapFrameElement) { item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected"); } const element = item.node.element(); if (element) { element.classList.remove("rw-cmap-selected"); element.removeAttribute("aria-selected"); this.removeHandles(element); } } if (this.selectedConnector) { const connector = this.selectedConnector; connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth }); connector.link.redraw(); } this.selectedItem = null; this.selectedItems.clear(); this.selectedConnector = null; if (clearedItemIds.length || clearedConnectorId) { debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId }); } if (notify) this.notifySelection(); } selected() { return this.selectedItem; } selectedAll() { return Array.from(this.selectedItems); } selectAll() { this.clearSelection(false); for (const item of this.items) { if (this.isEffectiveItemVisible(item)) this.selectedItems.add(item); } this.selectedItem = this.selectedAll().at(-1) || null; this.refreshSelectionDecoration(); this.notifySelection(); return this.selectedAll(); } canGroupSelection() { const selected = this.selectedAll(); return selected.length >= 2 && selected.every((item) => item.parentSubmap === selected[0].parentSubmap); } groupSelection(options = {}) { const selected = this.selectedAll(); if (!this.canGroupSelection()) return false; const parentSubmap = selected[0].parentSubmap; const left = Math.min(...selected.map((item) => Number(item.node.attr("x")))); const top = Math.min(...selected.map((item) => Number(item.node.attr("y")))); const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap"; const submap = this.addItem({ ...options, kind: "submap", label, childMap: options.childMap || label, synopsis: options.synopsis || "Grouped sub-concept map.", parentSubmap, submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, x: numberOr(Number(options.x), left), y: numberOr(Number(options.y), Math.max(20, top - 105)), backgroundColor: options.backgroundColor || "#edf7e8", borderColor: options.borderColor || "#57834a" }); submap.expanded = true; submap.submapInitialized = true; for (const item of selected) { item.groupId = null; item.parentSubmap = submap; this.updateSubmapDepth(item, submap.submapDepth + 1); } this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.refreshSubmapVisibility(); this.selectItem(submap); debug("selection grouped as submap", { submapId: submap.id, itemIds: selected.map((item) => item.id) }); return submap; } canUngroupSelection() { return this.selectedAll().some((item) => Boolean(item.groupId) || (item.kind === "submap" && !item.separateMap) || Boolean(item.parentSubmap && item.parentSubmap !== this.activeMapRoot)); } ungroupSelection() { const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean)); const affected = this.items.filter((item) => groupIds.has(item.groupId)); for (const item of affected) item.groupId = null; const selected = this.selectedAll(); const selectedSubmaps = new Set(selected.filter((item) => item.kind === "submap" && !item.separateMap)); const liftedChildren = new Set(); for (const submap of selectedSubmaps) { const parent = submap.parentSubmap; const children = this.items.filter((item) => item.parentSubmap === submap); for (const child of children) { liftedChildren.add(child); child.parentSubmap = parent; this.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0); } if (submap.submapFrameElement) { submap.submapFrameElement.remove(); submap.submapFrameElement = null; } submap.expanded = false; submap.submapInitialized = false; submap.childMap = null; this.updateItem(submap, { kind: "concept" }); affected.push(submap, ...children); } for (const item of selected) { if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap || item.parentSubmap === this.activeMapRoot) continue; const parent = item.parentSubmap.parentSubmap; item.parentSubmap = parent; this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0); affected.push(item); } if (!affected.length) return false; this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.refreshSubmapVisibility(); this.refreshSelectionDecoration(); debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) }); this.notifySelection(); this.scheduleHistoryCommit(); return true; } toDocument() { this.refreshConceptMapReferences(); return { schemaVersion: 1, items: this.items.map((record) => ({ id: record.id, kind: record.kind, label: record.label, synopsis: record.synopsis, pageSlug: record.pageSlug, cmapSlug: record.cmapSlug, parentCmapLink: record.parentCmapLink, groupId: record.groupId, childMap: record.childMap, parentSubmapId: record.parentSubmap ? record.parentSubmap.id : null, submapDepth: record.submapDepth, expanded: record.expanded, submapInitialized: record.submapInitialized, separateMap: record.separateMap, mapReference: record.mapReference, imageSource: record.imageSource, backgroundColor: record.backgroundColor, borderColor: record.borderColor, submapBackgroundColor: record.submapBackgroundColor, submapBorderColor: record.submapBorderColor, textColor: record.textColor, fontFamily: record.fontFamily, fontSize: record.fontSize, width: Number(record.node.attr("width")), height: Number(record.node.attr("height")), autoWidth: record.autoWidth, autoHeight: record.autoHeight, x: Number(record.node.attr("x")), y: Number(record.node.attr("y")) })), connectors: this.connectors.map((connector) => ({ id: connector.id, sourceId: connector.source.id, targetId: connector.target.id, hasArrow: connector.hasArrow, lineColor: connector.lineColor, lineWidth: connector.lineWidth })), conceptMaps: Array.from(this.conceptMaps.values()) }; } loadDocument(document = {}) { if (this.items.length || this.connectors.length) { throw new Error("A concept map document can only be loaded into an empty editor"); } const itemDocuments = Array.isArray(document.items) ? document.items : []; const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : []; const records = new Map(); for (const itemDocument of itemDocuments) { const record = this.addItem({ ...itemDocument, parentSubmap: null }); record.autoWidth = Boolean(itemDocument.autoWidth); record.autoHeight = Boolean(itemDocument.autoHeight); record.fitContentPending = false; record.expanded = Boolean(itemDocument.expanded); record.submapInitialized = Boolean(itemDocument.submapInitialized); records.set(record.id, record); } const groupNumbers = itemDocuments .map((item) => /^group-(\d+)$/.exec(item.groupId || "")) .filter(Boolean) .map((match) => Number(match[1])); this.nextGroupId = groupNumbers.length ? Math.max(...groupNumbers) + 1 : 1; for (const itemDocument of itemDocuments) { const record = records.get(Number(itemDocument.id)); const parent = records.get(Number(itemDocument.parentSubmapId)) || null; if (record) record.parentSubmap = parent; } for (const connectorDocument of connectorDocuments) { const source = records.get(Number(connectorDocument.sourceId)); const target = records.get(Number(connectorDocument.targetId)); if (!source || !target) continue; this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument); } this.conceptMaps = new Map( (Array.isArray(document.conceptMaps) ? document.conceptMaps : []) .filter((reference) => reference && reference.id) .map((reference) => [reference.id, reference])); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.refreshSubmapVisibility(); this.clearSelection(); if (!this.historyRestoring) this.resetHistory(); return this; } /** * goal : Start editing the currently selected item. * post : A phrase is edited inline; another item uses the host editor. * result : True when an item was available for editing. */ editSelected() { const record = this.selectedItem; if (!record) return false; if (record.kind === "phrase") { this.editPhraseInline(record); } else if (this.onEditItem) { this.onEditItem(record); } return true; } /** * goal : Start direct editing of a linking phrase. * pre : record.kind is "phrase". * post : An input appears in the relation-name node and receives focus. */ editPhraseInline(record) { if (!record || record.kind !== "phrase") return; const value = record.label || "?????"; record.node.attr("content", ``); record.node.redraw(); const element = record.node.element(); const input = element ? element.querySelector(".rw-cmap-phrase-input") : null; if (!input) { record.editWhenRendered = true; return; } const commit = () => { // Redrawing a phrase can synchronously fit it to its new label. Keep // that layout work inside this edit transaction without losing the // snapshot from before the text change. this.scheduleHistoryCommit(); const text = input.value.trim() || "?????"; record.label = text; record.node.attr("content", this.itemHtml(record)); record.node.redraw(); this.selectItem(record); }; input.addEventListener("pointerdown", (event) => event.stopPropagation()); input.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); input.blur(); } if (event.key === "Escape") { event.preventDefault(); input.value = value; input.blur(); } }); input.addEventListener("blur", commit, { once: true }); input.focus(); input.select(); } /** * goal : Redraw selection controls after ionstage/cmap updates a node. * pre : record.node.redraw() has made a DOM element available. * post : Selection, drag-to-link and resize interactions are attached. */ decorateItem(record, renderedElement = null) { const element = renderedElement || record.node.element(); if (!element) return; element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase"); element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`); element.dataset.rwCmapItemId = String(record.id); element.style.fontFamily = record.fontFamily; element.style.fontSize = record.fontSize; element.style.overflow = "visible"; if (record.fitContentPending) { this.fitItemToContent(record, element); } const image = element.querySelector(".cmap-card-image"); if (image && image.dataset.rwCmapFitBound !== "1") { image.dataset.rwCmapFitBound = "1"; image.addEventListener("load", () => { if (!record.autoWidth && !record.autoHeight) return; record.fitContentPending = true; this.fitItemToContent(record, element); }, { once: true }); } if (element.dataset.rwCmapBound !== "1") { element.dataset.rwCmapBound = "1"; debug("item pointer handlers attached", { id: record.id, kind: record.kind, element: elementDescription(element), style: selectionStyle(element) }); } if (this.selectedItems.has(record)) { element.classList.add("rw-cmap-selected"); element.setAttribute("aria-selected", "true"); if (this.selectedItem === record) this.ensureHandles(record, element); } this.ensureSubmapToggle(record, element); debug("cmap node rendered and decorated", { id: record.id, kind: record.kind, selected: this.selectedItems.has(record), element: elementDescription(element), style: selectionStyle(element) }); if (record.editWhenRendered) { record.editWhenRendered = false; queueMicrotask(() => this.editPhraseInline(record)); } this.ensureCanvasExtent( Number(record.node.attr("x")) + Number(record.node.attr("width")), Number(record.node.attr("y")) + Number(record.node.attr("height")) ); if (!record.moveMembership) { let parent = record.parentSubmap; while (parent) { this.updateSubmapFrame(parent); parent = parent.parentSubmap; } } } /** * goal : Give a new, not manually sized item a compact content-based size. * pre : element has been rendered and contains the current item HTML. * post : Automatic dimensions closely surround the text, with long text * wrapping at a practical maximum width. */ fitItemToContent(record, element) { record.fitContentPending = false; if (!record.autoWidth && !record.autoHeight) return; const probe = document.createElement("div"); probe.className = element.className; probe.innerHTML = this.itemHtml(record); Object.assign(probe.style, { position: "fixed", left: "-10000px", top: "0", width: "max-content", height: "auto", maxWidth: record.kind === "phrase" ? "280px" : "380px", boxSizing: "border-box", fontFamily: record.fontFamily, fontSize: record.fontSize, lineHeight: "1.25", overflow: "visible", pointerEvents: "none", transform: "none", visibility: "hidden", whiteSpace: "normal" }); const content = probe.firstElementChild; if (content) { Object.assign(content.style, { width: "max-content", height: "auto", maxWidth: record.kind === "phrase" ? "276px" : "376px", overflow: "visible", whiteSpace: "normal" }); } document.body.append(probe); const bounds = probe.getBoundingClientRect(); probe.remove(); const minimumWidth = record.kind === "phrase" ? 50 : 100; const minimumHeight = record.kind === "phrase" ? 24 : 40; const measuredWidth = Math.ceil(bounds.width) + 4; const measuredHeight = Math.ceil(bounds.height) + 4; const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width; const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : record.height; if (nextWidth === record.width && nextHeight === record.height) return; const previousWidth = record.width; const previousHeight = record.height; const attributes = { width: nextWidth, height: nextHeight }; if (record.kind === "phrase") { attributes.x = Number(record.node.attr("x")) + ((previousWidth - nextWidth) / 2); attributes.y = Number(record.node.attr("y")) + ((previousHeight - nextHeight) / 2); } record.width = nextWidth; record.height = nextHeight; record.node.attr(attributes); record.node.redraw(); this.redrawConnectorsFor(record); debug("automatic item size applied", { id: record.id, kind: record.kind, width: nextWidth, height: nextHeight }); this.refreshHistorySnapshot(); } decorateConnector(record, renderedElement = null) { const element = renderedElement || record.link.element(); if (!element) return; element.classList.add("rw-cmap-connector"); element.dataset.rwCmapConnectorId = String(record.id); } handleItemMove(record, x, y) { const movesCompleteSubmap = record.kind === "submap" && !record.expanded && record !== this.activeMapRoot; const movesSelection = this.selectedItems.has(record) && this.selectedItems.size > 1; const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection); if (movesCompleteSubmap || movesSelection) { this.moveSubmapGroup(record, x, y, moveMembership); } return { x, y }; } beginItemMove(record, includeDescendants = false) { if (record.moveMembership) return record.moveMembership; let groupItems = [record]; if (includeDescendants) { const selected = this.selectedItems.has(record) && this.selectedItems.size > 1 ? this.selectedAll() : [record]; const expanded = []; for (const item of selected) { expanded.push(item); if (item.kind === "submap" && !item.expanded && item !== this.activeMapRoot) { expanded.push(...this.items.filter((candidate) => this.isDescendantOf(candidate, item))); } } groupItems = Array.from(new Set(expanded)); } record.moveMembership = { parent: record.parentSubmap, parentBounds: record.parentSubmap ? this.submapBounds(record.parentSubmap) : null, startX: Number(record.node.attr("x")), startY: Number(record.node.attr("y")), groupPositions: groupItems.map((item) => ({ item, x: Number(item.node.attr("x")), y: Number(item.node.attr("y")) })) }; return record.moveMembership; } moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) { const deltaX = x - moveMembership.startX; const deltaY = y - moveMembership.startY; for (const position of moveMembership.groupPositions) { position.item.node.attr({ x: position.x + deltaX, y: position.y + deltaY }); position.item.node.redraw(); } for (const connector of this.connectors) { connector.link.straighten(); connector.link.redraw(); } for (const submap of this.items .filter((item) => item.kind === "submap") .sort((a, b) => b.submapDepth - a.submapDepth)) { this.updateSubmapFrame(submap); } } handleItemMoveEnd(record) { const moveMembership = record.moveMembership; record.moveMembership = null; if (!moveMembership) return; const movedItems = this.selectedAll().filter((item) => moveMembership.groupPositions.some((position) => position.item === item)); const movedParents = new Set(movedItems.map((item) => item.parentSubmap)); if (movedItems.length > 1 && movedParents.size === 1) { const previousParent = movedItems[0].parentSubmap; const centers = movedItems.map((item) => this.itemCenter(item)); const center = { x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length, y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length }; let parent = null; if (previousParent && this.pointInBounds(center, moveMembership.parentBounds)) { parent = previousParent; } else { parent = this.submapAtPoint(center, null, movedItems); } if (!parent && this.activeMapRoot && !movedItems.includes(this.activeMapRoot)) { parent = this.activeMapRoot; } if (previousParent && parent !== previousParent && this.onConfirmDetachFromSubmap && !this.onConfirmDetachFromSubmap(record, previousParent, parent)) { parent = previousParent; } if (parent !== previousParent) { for (const item of movedItems) { if (item === this.activeMapRoot) continue; item.parentSubmap = parent; this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0); } this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); debug("selection submap membership changed", { itemIds: movedItems.map((item) => item.id), previousParentId: previousParent ? previousParent.id : null, parentId: parent ? parent.id : null }); } this.refreshSubmapVisibility(); this.scheduleHistoryCommit(); return; } if (record.kind === "phrase") { this.scheduleHistoryCommit(); return; } // A separately opened map keeps its head linked to the parent map. Moving // that head edits its position inside the current view; it must not be // interpreted as dragging the complete map out of its parent submap. if (record === this.activeMapRoot) { this.refreshSubmapVisibility(); debug("active map head moved without changing parent membership", { id: record.id, parentId: record.parentSubmap ? record.parentSubmap.id : null }); this.scheduleHistoryCommit(); return; } const center = this.itemCenter(record); let parent = null; if (moveMembership.parent && this.pointInBounds(center, moveMembership.parentBounds)) { parent = moveMembership.parent; } else { parent = this.submapAtPoint(center, record); } if (!parent && this.activeMapRoot && record !== this.activeMapRoot) parent = this.activeMapRoot; if (moveMembership.parent && parent !== moveMembership.parent && this.onConfirmDetachFromSubmap && !this.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) { parent = moveMembership.parent; } if (parent !== record.parentSubmap) { const previousParent = record.parentSubmap; record.parentSubmap = parent; this.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0); debug("item submap membership changed", { id: record.id, previousParentId: previousParent ? previousParent.id : null, parentId: parent ? parent.id : null }); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); } this.refreshSubmapVisibility(); this.scheduleHistoryCommit(); } updateSubmapDepth(record, depth) { record.submapDepth = depth; for (const child of this.items.filter((item) => item.parentSubmap === record)) { this.updateSubmapDepth(child, depth + 1); } } pointInBounds(point, bounds) { return Boolean(bounds && point.x >= bounds.left && point.x <= bounds.right && point.y >= bounds.top && point.y <= bounds.bottom); } pointNearConnector(point, tolerance = 8 / this.zoomFactor) { const distanceToSegment = (start, end) => { const segmentX = end.x - start.x; const segmentY = end.y - start.y; const segmentLengthSquared = (segmentX * segmentX) + (segmentY * segmentY); if (segmentLengthSquared === 0) { return Math.hypot(point.x - start.x, point.y - start.y); } const projection = Math.max(0, Math.min(1, (((point.x - start.x) * segmentX) + ((point.y - start.y) * segmentY)) / segmentLengthSquared)); const nearestX = start.x + (projection * segmentX); const nearestY = start.y + (projection * segmentY); return Math.hypot(point.x - nearestX, point.y - nearestY); }; return this.connectors.some((connector) => { const source = connector.visualSource || this.connectorEndpoint(connector.source); const target = connector.visualTarget || this.connectorEndpoint(connector.target); if (!source || !target || source === target) return false; return distanceToSegment(this.itemCenter(source), this.itemCenter(target)) <= tolerance; }); } submapAtPoint(point, excludedRecord = null, excludedRecords = []) { const exclusions = new Set(excludedRecords); const candidates = this.items .filter((item) => item.kind === "submap" && item.expanded && item !== excludedRecord && !exclusions.has(item) && (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && !excludedRecords.some((record) => this.isDescendantOf(item, record)) && this.isItemVisible(item)) .sort((a, b) => b.submapDepth - a.submapDepth); const match = candidates.find((item) => this.pointInBounds(point, this.submapBounds(item, excludedRecord))); return match || (this.activeMapRoot && this.activeMapRoot !== excludedRecord && !exclusions.has(this.activeMapRoot) ? this.activeMapRoot : null); } submapBounds(record, excludedRecord = null) { const visibleItems = this.items.filter((item) => (item === record || this.isDescendantOf(item, record)) && item !== excludedRecord && (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && this.isItemVisible(item)); if (visibleItems.length === 0) return null; return { left: Math.min(...visibleItems.map((item) => Number(item.node.attr("x")))) - 34, top: Math.min(...visibleItems.map((item) => Number(item.node.attr("y")))) - 42, right: Math.max(...visibleItems.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 34, bottom: Math.max(...visibleItems.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 34 }; } visualEndpointFor(record) { if (this.isItemVisible(record)) return record; if (record.kind === "phrase" || this.activeMapRoot) return null; let parent = record.parentSubmap; while (parent) { if (this.isItemVisible(parent)) return parent; parent = parent.parentSubmap; } return null; } isEffectiveItemVisible(record) { if (!this.isItemVisible(record)) return false; if (record.kind !== "phrase") return true; const neighbours = this.connectors .filter((connector) => connector.source === record || connector.target === record) .map((connector) => connector.source === record ? connector.target : connector.source) .filter((item) => item.kind !== "phrase"); return neighbours.every((item) => Boolean(this.visualEndpointFor(item))); } connectorEndpoint(record) { if (record.kind === "phrase") { return this.isEffectiveItemVisible(record) ? record : null; } return this.visualEndpointFor(record); } applyConnectorVisualEndpoints(connector, source, target) { if (!source || !target || source === target) { connector.link.visible(false); return; } if (connector.visualSource !== source) { connector.link.sourceNode(source.node); connector.visualSource = source; } if (connector.visualTarget !== target) { connector.link.targetNode(target.node); connector.visualTarget = target; } connector.link.visible(true); connector.link.straighten(); connector.link.redraw(); } refreshSubmapVisibility() { for (const item of this.items) { if (item.kind === "submap" && item.separateMap) item.expanded = false; } for (const item of this.items) item.node.visible(this.isEffectiveItemVisible(item)); for (const connector of this.connectors) { this.applyConnectorVisualEndpoints(connector, this.connectorEndpoint(connector.source), this.connectorEndpoint(connector.target)); } const submaps = this.items .filter((item) => item.kind === "submap") .sort((a, b) => b.submapDepth - a.submapDepth); for (const submap of submaps) { this.updateSubmapFrame(submap); const element = submap.node.element(); if (element) this.ensureSubmapToggle(submap, element); } } updateSubmapFrame(record) { const surface = this.surfaceElement(); const shouldShow = !record.separateMap && record !== this.activeMapRoot && record.expanded && this.isItemVisible(record); if (!shouldShow || !surface) { if (record.submapFrameElement) { record.submapFrameElement.remove(); record.submapFrameElement = null; } return; } if (!record.submapFrameElement) { const frame = document.createElement("div"); frame.className = "rw-cmap-submap-frame"; frame.dataset.rwCmapSubmapId = String(record.id); frame.tabIndex = 0; frame.setAttribute("role", "group"); frame.setAttribute("aria-label", record.label); frame.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; if (this.pointNearConnector(this.canvasPoint(event))) return; this.startSubmapFrameDrag(event, record); }); frame.addEventListener("dblclick", (event) => { event.preventDefault(); event.stopPropagation(); this.selectItem(record); if (this.onEditItem) this.onEditItem(record); }); const collapse = document.createElement("button"); collapse.type = "button"; collapse.className = "rw-cmap-submap-frame-toggle"; collapse.textContent = "«"; collapse.title = "Collapse submap"; collapse.setAttribute("aria-label", collapse.title); collapse.setAttribute("aria-expanded", "true"); collapse.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); collapse.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.selectItem(record); this.toggleSubmap(record, false); }); frame.append(collapse); surface.prepend(frame); record.submapFrameElement = frame; } const bounds = this.submapBounds(record); if (!bounds) return; const { left, top, right, bottom } = bounds; Object.assign(record.submapFrameElement.style, { left: `${left}px`, top: `${top}px`, width: `${right - left}px`, height: `${bottom - top}px` }); record.submapFrameElement.style.setProperty( "--rw-cmap-submap-background", record.submapBackgroundColor || "#edf7e8"); record.submapFrameElement.style.setProperty( "--rw-cmap-submap-border", record.submapBorderColor || "#57834a"); record.submapFrameElement.classList.toggle( "rw-cmap-submap-frame-selected", this.selectedItems.has(record)); record.submapFrameElement.setAttribute("aria-label", record.label); this.ensureCanvasExtent(right, bottom); } startSubmapFrameDrag(event, record) { event.preventDefault(); event.stopPropagation(); const additive = event.ctrlKey || event.metaKey || event.shiftKey; if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(record)) { this.selectedItem = record; this.refreshSelectionDecoration(); this.notifySelection(); } else { this.selectItem(record, { additive }); } const pointerId = event.pointerId; const startClientX = event.clientX; const startClientY = event.clientY; const moveMembership = this.beginItemMove(record, true); const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; moveEvent.preventDefault(); const x = moveMembership.startX + ((moveEvent.clientX - startClientX) / this.zoomFactor); const y = moveMembership.startY + ((moveEvent.clientY - startClientY) / this.zoomFactor); this.moveSubmapGroup(record, x, y, moveMembership); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); this.handleItemMoveEnd(record); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } redrawConnectorsFor(record) { for (const connector of this.connectors) { if (connector.source === record || connector.target === record || connector.visualSource === record || connector.visualTarget === record) { connector.link.straighten(); connector.link.redraw(); } } } installMarqueeSelection() { this.marqueeMouseDownHandler = (event) => { if (event.button !== 0) return; if (!(event.target instanceof Element)) return; if (event.target.closest( "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return; const start = this.canvasPoint(event); if (this.pointNearConnector(start)) return; if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); const additive = event.ctrlKey || event.metaKey || event.shiftKey; const surface = this.surfaceElement() || this.canvas; const marquee = document.createElement("div"); marquee.className = "rw-cmap-marquee"; Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" }); surface.append(marquee); const cleanup = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); marquee.remove(); if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null; }; const move = (moveEvent) => { const point = this.canvasPoint(moveEvent); const left = Math.min(start.x, point.x); const top = Math.min(start.y, point.y); Object.assign(marquee.style, { left: `${left}px`, top: `${top}px`, width: `${Math.abs(point.x - start.x)}px`, height: `${Math.abs(point.y - start.y)}px` }); }; const up = (upEvent) => { const point = this.canvasPoint(upEvent); cleanup(); const bounds = { left: Math.min(start.x, point.x), top: Math.min(start.y, point.y), right: Math.max(start.x, point.x), bottom: Math.max(start.y, point.y) }; if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) { if (!additive) this.clearSelection(); return; } if (!additive) this.clearSelection(false); const matches = this.items.filter((item) => { if (!this.isEffectiveItemVisible(item)) return false; const left = Number(item.node.attr("x")); const top = Number(item.node.attr("y")); const right = left + Number(item.node.attr("width")); const bottom = top + Number(item.node.attr("height")); return right >= bounds.left && left <= bounds.right && bottom >= bounds.top && top <= bounds.bottom; }); const expanded = new Set(matches); for (const item of matches) { if (!item.groupId) continue; for (const member of this.items.filter((candidate) => candidate.groupId === item.groupId && this.isItemVisible(candidate))) expanded.add(member); } for (const item of expanded) this.selectedItems.add(item); this.selectedItem = matches.at(-1) || this.selectedItem; this.selectedConnector = null; this.refreshSelectionDecoration(); this.notifySelection(); debug("marquee selection applied", { selectedIds: this.selectedAll().map((item) => item.id) }); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); this.activeMarqueeCleanup = cleanup; }; this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler); } destroy() { if (this.historyTimer !== null) { window.clearTimeout(this.historyTimer); this.historyTimer = null; } if (this.marqueeMouseDownHandler) { this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler); this.marqueeMouseDownHandler = null; } if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); for (const item of this.items) { if (item.submapFrameElement) { item.submapFrameElement.remove(); item.submapFrameElement = null; } } debug("editor destroyed"); } deleteSelection() { const records = new Set(this.selectedAll().filter((item) => item !== this.activeMapRoot)); for (const record of Array.from(records)) { if (record.kind === "submap") { for (const item of this.items) { if (this.isDescendantOf(item, record)) records.add(item); } } } const connectors = new Set(this.connectors.filter((connector) => connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target))); let foundOrphan = true; while (foundOrphan) { foundOrphan = false; const remainingConnectors = this.connectors.filter((connector) => !connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target)); for (const phrase of this.items.filter((item) => item.kind === "phrase" && !records.has(item))) { const hasSource = remainingConnectors.some((connector) => connector.target === phrase); const hasTarget = remainingConnectors.some((connector) => connector.source === phrase); if (hasSource && hasTarget) continue; records.add(phrase); for (const connector of this.connectors) { if (connector.source === phrase || connector.target === phrase) connectors.add(connector); } foundOrphan = true; } } if (!records.size && !connectors.size) return false; this.clearSelection(false); for (const connector of connectors) connector.link.remove(); this.connectors = this.connectors.filter((connector) => !connectors.has(connector)); for (const record of records) { if (record.submapFrameElement) record.submapFrameElement.remove(); if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id); record.node.remove(); } this.items = this.items.filter((item) => !records.has(item)); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.refreshSubmapVisibility(); this.notifySelection(); debug("selection deleted", { itemIds: Array.from(records).map((item) => item.id), connectorIds: Array.from(connectors).map((connector) => connector.id) }); this.scheduleHistoryCommit(); return true; } /** * goal : Follow the component selected by cmap's coordinate hit test. * pre : component is a public node/link wrapper or null. * post : The corresponding wiki item or connector is selected; empty * canvas space clears the selection. */ handleMapSelection(component, event) { if (event.target instanceof Element && event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) { debug("cmap selection belongs to an editor control", elementDescription(event.target)); return; } const item = this.items.find((candidate) => candidate.node === component) || null; const connector = this.connectors.find((candidate) => candidate.link === component) || null; debug("selection callback received from cmap hit test", { componentFound: Boolean(component), itemId: item ? item.id : null, connectorId: connector ? connector.id : null, target: elementDescription(event.target) }); if (item) { const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey)); if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(item)) { this.selectedItem = item; this.refreshSelectionDecoration(); this.notifySelection(); return; } this.selectItem(item, { additive, toggle: additive }); return; } if (connector) { this.selectConnector(connector); return; } if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.clearSelection(); } /** * goal : Activate an item after cmap recognizes two stationary clicks. * pre : component is the public wrapper returned by cmap's hit test. * post : Page concepts navigate, submaps open and phrases enter editing. */ handleMapActivation(component, event) { const item = this.items.find((candidate) => candidate.node === component) || null; debug("activation callback received from cmap", { itemId: item ? item.id : null, kind: item ? item.kind : null, pageSlug: item ? item.pageSlug : null }); if (!item) return; if (event && event.preventDefault) event.preventDefault(); if (item.kind === "phrase") { this.editPhraseInline(item); return; } if (item.kind === "submap") { this.toggleSubmap(item); return; } if (item.parentCmapLink) { this.openParentMap(); return; } if (item.cmapSlug && this.onOpenCmap) { this.onOpenCmap(item); return; } if (item.pageSlug && this.onOpenPage) { this.onOpenPage(item); return; } } ensureSubmapToggle(record, element) { let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle"); if (record.kind !== "submap") { if (toggle) toggle.remove(); return; } if (record === this.activeMapRoot || (!record.separateMap && record.expanded)) { if (toggle) toggle.remove(); return; } if (!toggle) { toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "rw-cmap-submap-toggle"; toggle.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); toggle.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.selectItem(record); this.toggleSubmap(record); }); element.append(toggle); } toggle.textContent = record.separateMap ? "↗" : (record.expanded ? "−" : "+"); toggle.title = record.separateMap ? "Open concept map" : (record.expanded ? "Collapse submap" : "Expand submap"); toggle.setAttribute("aria-label", toggle.title); toggle.setAttribute("aria-expanded", String(record.expanded)); } ensureHandles(record, element) { if (!element.querySelector(":scope > .rw-cmap-relation-handle")) { const relation = document.createElement("button"); relation.type = "button"; relation.className = "rw-cmap-handle rw-cmap-relation-handle"; relation.title = this.labels.createRelation; relation.setAttribute("aria-label", this.labels.createRelation); relation.setAttribute("aria-hidden", "false"); relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record)); element.append(relation); } if (record.kind !== "phrase" && !element.querySelector(":scope > .rw-cmap-edit-handle")) { const edit = document.createElement("button"); edit.type = "button"; edit.className = "rw-cmap-handle rw-cmap-edit-handle"; edit.title = this.labels.editConcept; edit.setAttribute("aria-label", this.labels.editConcept); edit.setAttribute("aria-hidden", "false"); edit.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); edit.addEventListener("mousedown", (event) => event.stopPropagation()); edit.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.selectItem(record); if (this.onEditItem) this.onEditItem(record); }); element.append(edit); } if (record.kind !== "phrase" && !element.querySelector(":scope > .rw-cmap-resize-handle")) { const resize = document.createElement("button"); resize.type = "button"; resize.className = "rw-cmap-handle rw-cmap-resize-handle"; resize.title = this.labels.resizeConcept; resize.setAttribute("aria-label", this.labels.resizeConcept); resize.setAttribute("aria-hidden", "false"); resize.addEventListener("pointerdown", (event) => this.startResize(event, record)); element.append(resize); } } removeHandles(element) { for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove(); } startResize(event, record) { event.preventDefault(); event.stopPropagation(); record.autoWidth = false; record.autoHeight = false; record.fitContentPending = false; const startX = event.clientX; const startY = event.clientY; const startWidth = Number(record.node.attr("width")); const startHeight = Number(record.node.attr("height")); const pointerId = event.pointerId; event.currentTarget.setPointerCapture(pointerId); const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor)); record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor)); record.node.attr({ width: record.width, height: record.height }); record.node.redraw(); this.decorateItem(record); this.redrawConnectorsFor(record); this.ensureCanvasExtent(Number(record.node.attr("x")) + record.width, Number(record.node.attr("y")) + record.height); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); this.decorateItem(record); this.scheduleHistoryCommit(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } startRelationDrag(event, source) { event.preventDefault(); event.stopPropagation(); const pointerId = event.pointerId; const start = this.itemCenter(source); const draft = this.createDraftLine(start); this.dragRelation = { source, draft }; event.currentTarget.setPointerCapture(pointerId); const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; const point = this.canvasPoint(moveEvent); this.ensureCanvasExtent(point.x, point.y); draft.line.setAttribute("x2", String(point.x)); draft.line.setAttribute("y2", String(point.y)); draft.svg.setAttribute("width", String(Math.max(this.logicalCanvasWidth(), point.x + 180))); draft.svg.setAttribute("height", String(Math.max(this.logicalCanvasHeight(), point.y + 180))); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); const target = this.itemAt(upEvent.clientX, upEvent.clientY); const point = this.canvasPoint(upEvent); draft.svg.remove(); this.dragRelation = null; if (!target) { this.ensureCanvasExtent(point.x, point.y); const parentSubmap = this.submapAtPoint(point); debug("relation dropped on empty canvas", { sourceId: source.id, point, parentSubmapId: parentSubmap ? parentSubmap.id : null }); if (this.onCreateConnectedItem) this.onCreateConnectedItem({ source, point, parentSubmap }); return; } if (target === source) return; this.finishRelation(source, target); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } finishRelation(source, target) { if (source.kind === "phrase" && target.kind !== "phrase") { this.addConnector(source, target, true); this.reconcilePhraseMembership(source); this.refreshSubmapVisibility(); this.selectItem(source); return; } if (source.kind !== "phrase" && target.kind === "phrase") { this.addConnector(source, target, false); this.reconcilePhraseMembership(target); this.refreshSubmapVisibility(); this.selectItem(target); return; } if (source.kind === "phrase" && target.kind === "phrase") return; this.connectWithPhrase(source, target, "?????", true); } createDraftLine(start) { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.classList.add("rw-cmap-draft-layer"); svg.setAttribute("width", String(this.logicalCanvasWidth())); svg.setAttribute("height", String(this.logicalCanvasHeight())); const line = document.createElementNS(ns, "line"); line.setAttribute("x1", String(start.x)); line.setAttribute("y1", String(start.y)); line.setAttribute("x2", String(start.x)); line.setAttribute("y2", String(start.y)); line.setAttribute("class", "rw-cmap-draft-line"); svg.append(line); (this.surfaceElement() || this.canvas).append(svg); return { svg, line }; } logicalCanvasWidth() { const surface = this.surfaceElement(); return Math.max(this.canvas.clientWidth / this.zoomFactor, surface ? surface.scrollWidth : 0); } logicalCanvasHeight() { const surface = this.surfaceElement(); return Math.max(this.canvas.clientHeight / this.zoomFactor, surface ? surface.scrollHeight : 0); } canvasPoint(event) { const rect = this.canvas.getBoundingClientRect(); return { x: (event.clientX - rect.left + this.canvas.scrollLeft) / this.zoomFactor, y: (event.clientY - rect.top + this.canvas.scrollTop) / this.zoomFactor }; } itemAt(clientX, clientY) { const element = document.elementFromPoint(clientX, clientY); const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null; if (!itemElement) return null; const id = Number(itemElement.dataset.rwCmapItemId); return this.items.find((item) => item.id === id) || null; } itemCenter(record) { return { x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2), y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2) }; } itemHtml(record) { if (record.kind === "phrase") { return `