/* * Racket Wiki interaction layer for ionstage/cmap 0.1.3. * * This file contains the racket-wiki-specific editor model and interactions. * The installed upstream cmap.js is adjusted separately so its rendered nodes * accept pointer events; concepts can then be selected as normal DOM targets. */ (() => { "use strict"; const debugPrefix = "[racket-wiki:cmap 0.2.44]"; 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 CmapTools-like selection, resize and relation drawing on top * of ionstage/cmap without changing the upstream library. * 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.onSelectionChange = options.onSelectionChange || null; this.labels = { createRelation: options.createRelationLabel || "Create relation", resizeConcept: options.resizeConceptLabel || "Resize concept", relation: options.relationLabel || "Relation" }; this.map = this.CmapFactory(canvas); this.items = []; this.connectors = []; this.selectedItem = null; this.selectedConnector = null; this.nextId = 1; this.nextConnectorId = 1; this.dragRelation = null; this.installCanvasHandlers(); debug("editor created", { canvas: elementDescription(canvas), cmapFactoryAvailable: typeof this.CmapFactory === "function" }); } /** * 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 = {}) { const id = this.nextId++; const kind = options.kind || "concept"; const record = { id, kind, label: options.label || "Concept", synopsis: options.synopsis || "", pageSlug: options.pageSlug || null, childMap: options.childMap || null, backgroundColor: options.backgroundColor || "#f3f6f8", borderColor: options.borderColor || "#5d6d7e", fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif", fontSize: options.fontSize || "15px", width: options.width || (kind === "phrase" ? 145 : 220), height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)), 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" ? 1 : 2, textColor: "#222" }); record.node = node; this.items.push(record); node.redraw(); this.decorateItem(record); debug("item added", { id: record.id, kind: record.kind, label: record.label, element: elementDescription(record.node.element()), style: selectionStyle(record.node.element()) }); return record; } /** * 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 = {}) { for (const [key, value] of Object.entries(changes)) { if (value !== undefined) record[key] = value; } 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 }); record.node.redraw(); this.decorateItem(record); this.redrawConnectorsFor(record); } /** * 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 phrase = this.addItem({ kind: "phrase", label, x: ((a.x + b.x) / 2) - 72, y: ((a.y + b.y) / 2) - 18, width: 145, height: 36, backgroundColor: "#fffdf7", borderColor: "#8c7a4f" }); this.addConnector(source, phrase, false); this.addConnector(phrase, target, true); this.selectItem(phrase); if (editImmediately) this.editPhraseInline(phrase); return phrase; } /** * 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) { const link = this.map.link({ content: "", width: 1, height: 1, backgroundColor: "transparent", borderColor: "transparent", borderWidth: 0, lineColor: "#333", lineWidth: 2, hasArrow }); link.sourceNode(source.node).targetNode(target.node); link.draggable(true); link.redraw(); const record = { id: this.nextConnectorId++, link, source, target, hasArrow, lineColor: "#333", lineWidth: 2 }; this.connectors.push(record); this.decorateConnector(record); return record; } /** * goal : Select a concept/linking phrase and expose its handles. * pre : record belongs to this editor. * post : Previous selection is cleared and record is visually selected. */ selectItem(record) { debug("selectItem called", { requestedId: record ? record.id : null, requestedKind: record ? record.kind : null, previousId: this.selectedItem ? this.selectedItem.id : null }); this.clearSelection(); this.selectedItem = record; record.node.toFront(); const element = record.node.element(); if (element) { element.classList.add("rw-cmap-selected"); element.setAttribute("aria-selected", "true"); this.ensureHandles(record, element); } debug("selection applied", { selectedId: this.selectedItem ? this.selectedItem.id : null, elementFound: Boolean(element), 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) }); if (element && typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(() => { debug("selection after browser redraw", { selectedId: this.selectedItem ? this.selectedItem.id : null, selectedClassPresent: element.classList.contains("rw-cmap-selected"), connected: element.isConnected, handleCount: element.querySelectorAll(":scope > .rw-cmap-handle").length, computedStyle: selectionStyle(element) }); }); } this.notifySelection(); } /** * 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.decorateConnector(record); this.notifySelection(); } /** * goal : Remove the current item/connector selection. * post : No item handles or connector highlight remain. */ clearSelection() { const clearedItemId = this.selectedItem ? this.selectedItem.id : null; const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null; if (this.selectedItem) { const element = this.selectedItem.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.decorateConnector(connector); } this.selectedItem = null; this.selectedConnector = null; if (clearedItemId || clearedConnectorId) { debug("selection cleared", { itemId: clearedItemId, connectorId: clearedConnectorId }); } this.notifySelection(); } selected() { return this.selectedItem; } /** * 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(); this.decorateItem(record); const element = record.node.element(); const input = element ? element.querySelector(".rw-cmap-phrase-input") : null; if (!input) return; const commit = () => { const text = input.value.trim() || "?????"; record.label = text; record.node.attr("content", this.itemHtml(record)); record.node.redraw(); this.decorateItem(record); 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) { const element = record.node.element(); if (!element) return; 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 (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) }); element.addEventListener("dblclick", (event) => { if (event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) return; event.preventDefault(); event.stopPropagation(); if (record.kind === "phrase") { this.editPhraseInline(record); return; } if (record.pageSlug && this.onOpenPage) { this.onOpenPage(record); return; } if (record.childMap && this.onOpenSubMap) this.onOpenSubMap(record); }); } if (this.selectedItem === record) { element.classList.add("rw-cmap-selected"); this.ensureHandles(record, element); } } decorateConnector(record) { const element = record.link.element(); if (!element) return; element.classList.add("rw-cmap-connector"); element.dataset.rwCmapConnectorId = String(record.id); if (element.dataset.rwCmapBound !== "1") { element.dataset.rwCmapBound = "1"; element.addEventListener("pointerdown", (event) => { event.stopPropagation(); this.selectConnector(record); }, true); } } redrawConnectorsFor(record) { for (const connector of this.connectors) { if (connector.source === record || connector.target === record) { connector.link.redraw(); this.decorateConnector(connector); } } } /** * goal : Select concepts before ionstage/cmap starts a possible drag. * pre : Item DOM elements contain data-rw-cmap-item-id attributes. * post : Pointer-down on an item selects it immediately; pointer-down on * empty canvas space clears the selection. */ installCanvasHandlers() { const itemFromEvent = (event) => { if (!(event.target instanceof Element)) return null; const element = event.target.closest("[data-rw-cmap-item-id]"); if (!element || !this.canvas.contains(element)) return null; const id = Number(element.dataset.rwCmapItemId); return this.items.find((item) => item.id === id) || null; }; this.canvas.addEventListener("pointerdown", (event) => { debug("canvas pointerdown", { pointerId: event.pointerId, pointerType: event.pointerType, button: event.button, target: elementDescription(event.target) }); if (event.target instanceof Element && event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) { debug("pointerdown belongs to a selection control", elementDescription(event.target)); return; } const item = itemFromEvent(event); if (item) { debug("pointerdown matched item", { id: item.id, kind: item.kind, label: item.label }); this.selectItem(item); return; } debug("pointerdown did not match an item", { targetIsCanvas: event.target === this.canvas, target: elementDescription(event.target) }); if (event.target === this.canvas) this.clearSelection(); }, true); } ensureHandles(record, element) { this.removeHandles(element); 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") { 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(); 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)); record.height = Math.max(42, startHeight + (moveEvent.clientY - startY)); record.node.attr({ width: record.width, height: record.height }); record.node.redraw(); this.decorateItem(record); this.redrawConnectorsFor(record); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); this.decorateItem(record); }; 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); draft.line.setAttribute("x2", String(point.x)); draft.line.setAttribute("y2", String(point.y)); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); draft.svg.remove(); this.dragRelation = null; const target = this.itemAt(upEvent.clientX, upEvent.clientY); if (!target || 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.selectItem(source); return; } if (source.kind !== "phrase" && target.kind === "phrase") { this.addConnector(source, target, false); 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(Math.max(this.canvas.scrollWidth, this.canvas.clientWidth))); svg.setAttribute("height", String(Math.max(this.canvas.scrollHeight, this.canvas.clientHeight))); 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.canvas.append(svg); return { svg, line }; } canvasPoint(event) { const rect = this.canvas.getBoundingClientRect(); return { x: event.clientX - rect.left + this.canvas.scrollLeft, y: event.clientY - rect.top + this.canvas.scrollTop }; } 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 `