"use strict"; import { debug, elementDescription, selectionStyle, escapeHtml } from "../cmap-utils.js"; export class CmapItemDecorator { constructor(editor) { this.editor = editor; } get items() { return this.editor.items; } get connectors() { return this.editor.connectors; } get canvas() { return this.editor.canvas; } get zoomFactor() { return this.editor.zoomFactor; } itemHtml(record) { if (record.kind === "phrase") { return `
${escapeHtml(record.label || "?????")}
`; } if (this.editor.renderItem) return this.editor.renderItem(record); return `
${escapeHtml(record.label)}
`; } editPhraseInline(record) { if (!record || record.kind !== "phrase") return; const value = record.label || "?????"; record.node.attr("content", ``); record.node.redraw(); const element = record.node.element(); const input = element ? element.querySelector(".rw-cmap-phrase-input") : null; if (!input) { record.editWhenRendered = true; return; } const commit = () => { this.editor.scheduleHistoryCommit(); const text = input.value.trim() || "?????"; record.label = text; record.node.attr("content", this.itemHtml(record)); record.node.redraw(); this.editor.selectItem(record); }; input.addEventListener("pointerdown", (event) => event.stopPropagation()); input.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); input.blur(); } if (event.key === "Escape") { event.preventDefault(); input.value = value; input.blur(); } }); input.addEventListener("blur", commit, { once: true }); input.focus(); input.select(); } applyItemTypography(record, element) { const title = element.querySelector(".cmap-card-title"); if (title) { title.style.color = record.textColor; title.style.fontFamily = record.fontFamily; title.style.fontSize = record.fontSize; title.style.fontWeight = record.fontWeight; title.style.fontStyle = record.fontStyle; } const synopsis = element.querySelector(".cmap-card-synopsis"); if (synopsis) { synopsis.style.color = record.synopsisTextColor; synopsis.style.fontFamily = record.synopsisFontFamily; synopsis.style.fontSize = record.synopsisFontSize; synopsis.style.fontWeight = record.synopsisFontWeight; synopsis.style.fontStyle = record.synopsisFontStyle; } } decorateItem(record, renderedElement = null) { const element = renderedElement || record.node.element(); if (!element) return; element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase"); element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`); element.dataset.rwCmapItemId = String(record.id); element.style.fontFamily = record.fontFamily; element.style.fontSize = record.fontSize; element.style.fontWeight = record.fontWeight; element.style.fontStyle = record.fontStyle; element.style.overflow = "visible"; this.applyItemTypography(record, element); if (record.fitContentPending || record.kind !== "phrase") { this.fitItemToContent(record, element); } const image = element.querySelector(".cmap-card-image"); if (image && image.dataset.rwCmapFitBound !== "1") { image.dataset.rwCmapFitBound = "1"; image.addEventListener("load", () => { if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return; record.fitContentPending = true; this.fitItemToContent(record, element); }, { once: true }); } const descriptionButton = element.querySelector(".rw-cmap-view-description"); if (descriptionButton && descriptionButton.dataset.rwCmapBound !== "1") { descriptionButton.dataset.rwCmapBound = "1"; descriptionButton.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); descriptionButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (record.descriptionPageSlug && this.editor.onOpenPage) { this.editor.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug }); } }); } const linkedButton = element.querySelector(".rw-cmap-open-linked"); if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") { linkedButton.dataset.rwCmapBound = "1"; linkedButton.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); linkedButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (record.parentCmapLink) { if (this.editor.onOpenParentCmap) this.editor.onOpenParentCmap(record); else this.editor.openParentMap(); } else if (record.cmapSlug && this.editor.onOpenCmap) { this.editor.onOpenCmap(record); } else if (record.pageSlug && this.editor.onOpenPage) { this.editor.onOpenPage(record); } }); } const externalButton = element.querySelector(".rw-cmap-open-external"); if (externalButton && externalButton.dataset.rwCmapBound !== "1") { externalButton.dataset.rwCmapBound = "1"; externalButton.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); externalButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (record.externalUrl && this.editor.onOpenExternalUrl) this.editor.onOpenExternalUrl(record); }); } if (element.dataset.rwCmapBound !== "1") { element.dataset.rwCmapBound = "1"; debug("item pointer handlers attached", { id: record.id, kind: record.kind, element: elementDescription(element), style: selectionStyle(element) }); } if (this.editor.selectedItems.has(record)) { element.classList.add("rw-cmap-selected"); element.classList.toggle("rw-cmap-selected-primary", this.editor.selectedItem === record); element.setAttribute("aria-selected", "true"); if (this.editor.selectedItem === record) this.ensureHandles(record, element); } this.ensureSubmapToggle(record, element); debug("cmap node rendered and decorated", { id: record.id, kind: record.kind, selected: this.editor.selectedItems.has(record), element: elementDescription(element), style: selectionStyle(element) }); if (record.editWhenRendered) { record.editWhenRendered = false; queueMicrotask(() => this.editPhraseInline(record)); } this.editor.ensureCanvasExtent( Number(record.node.attr("x")) + Number(record.node.attr("width")), Number(record.node.attr("y")) + Number(record.node.attr("height")) ); if (!record.moveMembership) { let parent = record.parentSubmap; while (parent) { this.editor.updateSubmapFrame(parent); parent = parent.parentSubmap; } } } fitItemToContent(record, element) { record.fitContentPending = false; if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return; const fixedWidth = !record.autoWidth && record.kind !== "phrase"; const probe = document.createElement("div"); probe.className = element.className; probe.innerHTML = this.itemHtml(record); Object.assign(probe.style, { position: "fixed", left: "-10000px", top: "0", width: fixedWidth ? `${record.width}px` : "max-content", height: "auto", maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"), boxSizing: "border-box", fontFamily: record.fontFamily, fontSize: record.fontSize, fontWeight: record.fontWeight, fontStyle: record.fontStyle, lineHeight: "1.25", overflow: "visible", pointerEvents: "none", transform: "none", visibility: "hidden", whiteSpace: "normal" }); this.applyItemTypography(record, probe); const content = probe.firstElementChild; if (content) { Object.assign(content.style, { width: fixedWidth ? "100%" : "max-content", height: "auto", maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"), overflow: "visible", whiteSpace: "normal" }); } document.body.append(probe); const bounds = probe.getBoundingClientRect(); probe.remove(); const minimumWidth = record.kind === "phrase" ? 50 : 100; const minimumHeight = record.kind === "phrase" ? 24 : 40; const measuredWidth = Math.ceil(bounds.width) + 4; const measuredHeight = Math.ceil(bounds.height) + 4; const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width; const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : (record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight)); if (nextWidth === record.width && nextHeight === record.height) return; const beforeAutomaticLayout = this.editor.onAutomaticLayoutChange ? this.editor.historySnapshot() : null; const previousWidth = record.width; const previousHeight = record.height; const attributes = { width: nextWidth, height: nextHeight }; if (record.kind === "phrase") { attributes.x = Number(record.node.attr("x")) + ((previousWidth - nextWidth) / 2); attributes.y = Number(record.node.attr("y")) + ((previousHeight - nextHeight) / 2); } record.width = nextWidth; record.height = nextHeight; record.node.attr(attributes); record.node.redraw(); this.editor.redrawConnectorsFor(record); debug("automatic item size applied", { id: record.id, kind: record.kind, width: nextWidth, height: nextHeight }); this.editor.refreshHistorySnapshot(); if (this.editor.onAutomaticLayoutChange) { const afterAutomaticLayout = this.editor.historySnapshot(); if (beforeAutomaticLayout !== afterAutomaticLayout) { this.editor.onAutomaticLayoutChange({ beforeSnapshot: beforeAutomaticLayout, afterSnapshot: afterAutomaticLayout, itemId: record.id }); } } } decorateConnector(record, renderedElement = null) { const element = renderedElement || record.link.element(); if (!element) return; element.classList.add("rw-cmap-connector"); element.dataset.rwCmapConnectorId = String(record.id); } ensureSubmapToggle(record, element) { let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle"); let open = element.querySelector(":scope > .rw-cmap-submap-open"); if (record.kind !== "submap") { if (toggle) toggle.remove(); if (open) open.remove(); return; } if (record === this.editor.activeMapRoot) { if (toggle) toggle.remove(); if (open) open.remove(); return; } const legacySeparateMap = record.separateMap && !record.cmapSlug; if (record.expanded && !legacySeparateMap) { if (toggle) toggle.remove(); toggle = null; } if (!toggle) { if (!record.expanded || legacySeparateMap) { toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "rw-cmap-submap-toggle"; toggle.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); toggle.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.editor.selectItem(record); this.editor.toggleSubmap(record); }); element.append(toggle); } } if (toggle) { toggle.textContent = legacySeparateMap ? "↗" : "+"; toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap"; toggle.setAttribute("aria-label", toggle.title); toggle.setAttribute("aria-expanded", String(record.expanded)); } if (record.cmapSlug && this.editor.onOpenStoredSubMap) { if (!open) { open = document.createElement("button"); open.type = "button"; open.className = "rw-cmap-submap-open"; open.textContent = "↗"; open.title = "Open as separate concept map"; open.setAttribute("aria-label", open.title); open.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); open.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.editor.selectItem(record); this.editor.onOpenStoredSubMap(record); }); element.append(open); } } else if (open) { open.remove(); } } ensureHandles(record, element) { if (!element.querySelector(":scope > .rw-cmap-relation-handle")) { const relation = document.createElement("button"); relation.type = "button"; relation.className = "rw-cmap-handle rw-cmap-relation-handle"; relation.title = this.editor.labels.createRelation; relation.setAttribute("aria-label", this.editor.labels.createRelation); relation.setAttribute("aria-hidden", "false"); relation.addEventListener("pointerdown", (event) => this.editor.startRelationDrag(event, record)); element.append(relation); } if (record.kind !== "phrase" && !element.querySelector(":scope > .rw-cmap-edit-handle")) { const edit = document.createElement("button"); edit.type = "button"; edit.className = "rw-cmap-handle rw-cmap-edit-handle"; edit.title = this.editor.labels.editConcept; edit.setAttribute("aria-label", this.editor.labels.editConcept); edit.setAttribute("aria-hidden", "false"); edit.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); edit.addEventListener("mousedown", (event) => event.stopPropagation()); edit.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.editor.selectItem(record); if (this.editor.onEditItem) this.editor.onEditItem(record); }); element.append(edit); } if (record.kind !== "phrase" && !element.querySelector(":scope > .rw-cmap-resize-handle")) { const resize = document.createElement("button"); resize.type = "button"; resize.className = "rw-cmap-handle rw-cmap-resize-handle"; resize.title = this.editor.labels.resizeConcept; resize.setAttribute("aria-label", this.editor.labels.resizeConcept); resize.setAttribute("aria-hidden", "false"); resize.addEventListener("pointerdown", (event) => this.editor.startResize(event, record)); element.append(resize); } } removeHandles(element) { for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove(); } boundaryConceptFor(record, crossedConnector, inside) { if (!record || record.kind !== "phrase") return record; for (const connector of this.connectors) { if (connector === crossedConnector) continue; if (connector.source !== record && connector.target !== record) continue; const neighbour = connector.source === record ? connector.target : connector.source; if (neighbour.kind === "phrase") continue; if (this.editor.itemInsideActiveMap(neighbour) === inside) return neighbour; } return record; } refreshBoundaryReferences() { if (this.editor.boundaryLayer) { this.editor.boundaryLayer.remove(); this.editor.boundaryLayer = null; } if (!this.editor.activeMapRoot) return; const surface = this.editor.surfaceElement(); if (!surface) return; const crossings = []; for (const connector of this.connectors) { const sourceInside = this.editor.itemInsideActiveMap(connector.source); const targetInside = this.editor.itemInsideActiveMap(connector.target); if (sourceInside === targetInside) continue; const insideRecord = sourceInside ? connector.source : connector.target; const outsideRecord = sourceInside ? connector.target : connector.source; const insideConcept = this.boundaryConceptFor(insideRecord, connector, true); const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false); if (!insideConcept || !outsideConcept || !this.editor.isItemVisible(insideConcept)) continue; crossings.push({ connector, sourceInside, insideConcept, outsideConcept }); } if (!crossings.length) return; const layer = document.createElement("div"); layer.className = "rw-cmap-boundary-layer"; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("rw-cmap-boundary-lines"); const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs"); const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); marker.setAttribute("id", "rw-cmap-boundary-arrow"); marker.setAttribute("viewBox", "0 0 10 10"); marker.setAttribute("refX", "9"); marker.setAttribute("refY", "5"); marker.setAttribute("markerWidth", "7"); marker.setAttribute("markerHeight", "7"); marker.setAttribute("orient", "auto-start-reverse"); const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path"); arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z"); arrow.setAttribute("fill", "#4a5560"); marker.append(arrow); definitions.append(marker); svg.append(definitions); layer.append(svg); surface.append(layer); this.editor.boundaryLayer = layer; const viewLeft = this.canvas.scrollLeft / this.zoomFactor; const viewTop = this.canvas.scrollTop / this.zoomFactor; const viewWidth = this.canvas.clientWidth / this.zoomFactor; const viewHeight = this.canvas.clientHeight / this.zoomFactor; const buttonWidth = 190; const occupied = { left: [], right: [] }; const reserveY = (side, desired) => { let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40)); while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34; if (y > viewTop + viewHeight - 40) y = viewTop + 12; occupied[side].push(y); return y; }; for (const crossing of crossings) { const insideX = Number(crossing.insideConcept.node.attr("x")) + (Number(crossing.insideConcept.node.attr("width")) / 2); const insideY = Number(crossing.insideConcept.node.attr("y")) + (Number(crossing.insideConcept.node.attr("height")) / 2); const outsideX = Number(crossing.outsideConcept.node.attr("x")) + (Number(crossing.outsideConcept.node.attr("width")) / 2); const side = outsideX < insideX ? "left" : "right"; const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12; const y = reserveY(side, insideY - 15); const button = document.createElement("button"); button.type = "button"; button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`; button.style.transform = `translate(${x}px, ${y}px)`; button.style.width = `${buttonWidth}px`; button.textContent = crossing.outsideConcept.label || "External concept"; button.title = "Open the concept map containing this connection"; button.addEventListener("click", () => { if (this.editor.onOpenBoundaryReference) { this.editor.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector); } }); layer.append(button); const boundaryX = side === "left" ? x + buttonWidth : x; const boundaryY = y + 15; const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const startX = crossing.sourceInside ? insideX : boundaryX; const startY = crossing.sourceInside ? insideY : boundaryY; const endX = crossing.sourceInside ? boundaryX : insideX; const endY = crossing.sourceInside ? boundaryY : insideY; path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`); path.setAttribute("fill", "none"); path.setAttribute("stroke", crossing.connector.lineColor || "#4a5560"); path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2)); if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)"); svg.append(path); } } updateSubmapAnchorLine(record, bounds, surface = this.editor.surfaceElement()) { if (!surface || !bounds || record === this.editor.activeMapRoot || !record.expanded) { if (record.submapAnchorLineElement) { record.submapAnchorLineElement.remove(); record.submapAnchorLineElement = null; } return; } if (!record.submapAnchorLineElement) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("rw-cmap-submap-anchor-line"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); svg.append(path); surface.prepend(svg); record.submapAnchorLineElement = svg; } const anchor = this.editor.itemCenter(record); const target = { x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)), y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom)) }; record.submapAnchorLineElement.querySelector("path") .setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`); } }