"use strict"; import { debug, elementDescription } from "../cmap-utils.js"; export class CmapInteractionController { constructor(editor) { this.editor = editor; this.marqueeMouseDownHandler = null; this.activeMarqueeCleanup = null; this.canvasPanPointerDownHandler = null; this.activeCanvasPanCleanup = null; this.boundaryRefreshScheduled = false; } get items() { return this.editor.items; } get connectors() { return this.editor.connectors; } get canvas() { return this.editor.canvas; } get zoomFactor() { return this.editor.zoomFactor; } handleItemMove(record, x, y) { const movesCompleteSubmap = false; const movesSelection = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1; const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection); if (moveMembership.groupPositions.length > 1) { this.moveSubmapGroup(record, x, y, moveMembership); queueMicrotask(() => this.redrawAllConnectors()); } this.scheduleBoundaryRefresh(); return { x, y }; } scheduleBoundaryRefresh() { if (this.boundaryRefreshScheduled) return; this.boundaryRefreshScheduled = true; window.requestAnimationFrame(() => { this.boundaryRefreshScheduled = false; if (!this.editor.destroyed) this.editor.refreshBoundaryReferences(); }); } beginItemMove(record, includeDescendants = false) { if (record.moveMembership) return record.moveMembership; let groupItems = [record]; if (includeDescendants) { const selected = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1 ? this.editor.selectedAll() : [record]; const expanded = []; for (const item of selected) { expanded.push(item); if (item.kind === "submap" && item !== this.editor.activeMapRoot) { expanded.push(...this.items.filter((candidate) => this.editor.isDescendantOf(candidate, item))); } } groupItems = Array.from(new Set(expanded)); } groupItems = this.includeLinkedMapItems(groupItems); record.moveMembership = { parent: record.parentSubmap, parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null, startX: Number(record.node.attr("x")), startY: Number(record.node.attr("y")), groupPositions: groupItems.map((item) => ({ item, x: Number(item.node.attr("x")), y: Number(item.node.attr("y")) })) }; return record.moveMembership; } includeLinkedMapItems(groupItems) { const group = new Set(groupItems); const pending = [...groupItems]; while (pending.length) { const current = pending.pop(); for (const connector of this.connectors) { if (connector.source !== current && connector.target !== current) continue; const other = connector.source === current ? connector.target : connector.source; const include = other.kind === "phrase" || Boolean(other.cmapSlug); if (!include || group.has(other)) continue; group.add(other); pending.push(other); } } return Array.from(group); } moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) { const deltaX = x - moveMembership.startX; const deltaY = y - moveMembership.startY; for (const position of moveMembership.groupPositions) { position.item.node.attr({ x: position.x + deltaX, y: position.y + deltaY }); position.item.node.redraw(); } for (const connector of this.connectors) { connector.link.straighten(); connector.link.redraw(); } for (const submap of this.items .filter((item) => item.kind === "submap") .sort((a, b) => b.submapDepth - a.submapDepth)) { this.editor.updateSubmapFrame(submap); } } redrawAllConnectors() { for (const connector of this.connectors) { connector.link.straighten(); connector.link.redraw(); } } handleItemMoveEnd(record) { const moveMembership = record.moveMembership; record.moveMembership = null; if (!moveMembership) return; this.redrawAllConnectors(); const movedItems = this.editor.selectedAll().filter((item) => moveMembership.groupPositions.some((position) => position.item === item)); const movedParents = new Set(movedItems.map((item) => item.parentSubmap)); if (movedItems.length > 1 && movedParents.size === 1) { const previousParent = movedItems[0].parentSubmap; const centers = movedItems.map((item) => this.editor.itemCenter(item)); const center = { x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length, y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length }; let parent = null; if (previousParent && this.editor.pointInBounds(center, moveMembership.parentBounds)) { parent = previousParent; } else { parent = this.editor.submapAtPoint(center, null, movedItems); } if (!parent && this.editor.activeMapRoot && !movedItems.includes(this.editor.activeMapRoot)) { parent = this.editor.activeMapRoot; } if (previousParent && parent !== previousParent && this.editor.onConfirmDetachFromSubmap && !this.editor.onConfirmDetachFromSubmap(record, previousParent, parent)) { parent = previousParent; } if (parent !== previousParent) { for (const item of movedItems) { if (item === this.editor.activeMapRoot) continue; item.parentSubmap = parent; this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0); } this.editor.reconcilePhraseMembership(); this.editor.refreshConceptMapReferences(); debug("selection submap membership changed", { itemIds: movedItems.map((item) => item.id), previousParentId: previousParent ? previousParent.id : null, parentId: parent ? parent.id : null }); } this.editor.refreshSubmapVisibility(); this.editor.scheduleHistoryCommit(); return; } if (record.kind === "phrase") { this.editor.scheduleHistoryCommit(); return; } if (record === this.editor.activeMapRoot) { this.editor.refreshSubmapVisibility(); debug("active map head moved without changing parent membership", { id: record.id, parentId: record.parentSubmap ? record.parentSubmap.id : null }); this.editor.scheduleHistoryCommit(); return; } const center = this.editor.itemCenter(record); let parent = null; if (moveMembership.parent && this.editor.pointInBounds(center, moveMembership.parentBounds)) { parent = moveMembership.parent; } else { parent = this.editor.submapAtPoint(center, record); } if (!parent && this.editor.activeMapRoot && record !== this.editor.activeMapRoot) parent = this.editor.activeMapRoot; if (moveMembership.parent && parent !== moveMembership.parent && this.editor.onConfirmDetachFromSubmap && !this.editor.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) { parent = moveMembership.parent; } if (parent !== record.parentSubmap) { const previousParent = record.parentSubmap; record.parentSubmap = parent; this.editor.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0); debug("item submap membership changed", { id: record.id, previousParentId: previousParent ? previousParent.id : null, parentId: parent ? parent.id : null }); this.editor.reconcilePhraseMembership(); this.editor.refreshConceptMapReferences(); } this.editor.refreshSubmapVisibility(); this.editor.scheduleHistoryCommit(); } startSubmapFrameDrag(event, record) { event.preventDefault(); event.stopPropagation(); const additive = event.ctrlKey || event.metaKey || event.shiftKey; if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(record)) { this.editor.selectedItem = record; this.editor.refreshSelectionDecoration(); this.editor.notifySelection(); } else { this.editor.selectItem(record, { additive }); } const pointerId = event.pointerId; const startClientX = event.clientX; const startClientY = event.clientY; const bounds = this.editor.submapBounds(record); if (!bounds) return; const moveMembership = { parent: record.parentSubmap, parentBounds: null, startX: bounds.left, startY: bounds.top, groupPositions: this.items .filter((item) => this.editor.isDescendantOf(item, record)) .map((item) => ({ item, x: Number(item.node.attr("x")), y: Number(item.node.attr("y")) })) }; record.moveMembership = moveMembership; const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; moveEvent.preventDefault(); const x = moveMembership.startX + ((moveEvent.clientX - startClientX) / this.zoomFactor); const y = moveMembership.startY + ((moveEvent.clientY - startClientY) / this.zoomFactor); this.moveSubmapGroup(record, x, y, moveMembership); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); record.moveMembership = null; this.editor.saveCurrentContextLayout(); this.editor.refreshSubmapVisibility(); this.editor.scheduleHistoryCommit(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } installMarqueeSelection() { this.marqueeMouseDownHandler = (event) => { if (event.button !== 0) return; if (!(event.target instanceof Element)) return; if (event.target.closest( "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return; const start = this.editor.canvasPoint(event); if (this.editor.pointNearConnector(start)) return; if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); const additive = event.ctrlKey || event.metaKey || event.shiftKey; const surface = this.editor.surfaceElement() || this.canvas; const marquee = document.createElement("div"); marquee.className = "rw-cmap-marquee"; Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" }); surface.append(marquee); const cleanup = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); marquee.remove(); if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null; }; const move = (moveEvent) => { const point = this.editor.canvasPoint(moveEvent); const left = Math.min(start.x, point.x); const top = Math.min(start.y, point.y); Object.assign(marquee.style, { left: `${left}px`, top: `${top}px`, width: `${Math.abs(point.x - start.x)}px`, height: `${Math.abs(point.y - start.y)}px` }); }; const up = (upEvent) => { const point = this.editor.canvasPoint(upEvent); cleanup(); const bounds = { left: Math.min(start.x, point.x), top: Math.min(start.y, point.y), right: Math.max(start.x, point.x), bottom: Math.max(start.y, point.y) }; if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) { if (!additive) this.editor.clearSelection(); return; } if (!additive) this.editor.clearSelection(false); const matches = this.items.filter((item) => { if (!this.editor.isEffectiveItemVisible(item)) return false; const left = Number(item.node.attr("x")); const top = Number(item.node.attr("y")); const right = left + Number(item.node.attr("width")); const bottom = top + Number(item.node.attr("height")); return right >= bounds.left && left <= bounds.right && bottom >= bounds.top && top <= bounds.bottom; }); const expanded = new Set(matches); for (const item of matches) { if (!item.groupId) continue; for (const member of this.items.filter((candidate) => candidate.groupId === item.groupId && this.editor.isItemVisible(candidate))) expanded.add(member); } for (const item of expanded) this.editor.selectedItems.add(item); this.editor.selectedItem = matches.at(-1) || this.editor.selectedItem; this.editor.selectedConnector = null; this.editor.refreshSelectionDecoration(); this.editor.notifySelection(); debug("marquee selection applied", { selectedIds: this.editor.selectedAll().map((item) => item.id) }); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); this.activeMarqueeCleanup = cleanup; }; this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler); } installCanvasPanning() { this.canvasPanPointerDownHandler = (event) => { const target = event.target instanceof Element ? event.target : null; if (target && target.closest( "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return; if (event.button !== 1 && !(event.button === 0 && event.altKey)) return; event.preventDefault(); if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup(); const startX = event.clientX; const startY = event.clientY; const startScrollLeft = this.canvas.scrollLeft; const startScrollTop = this.canvas.scrollTop; const pointerId = event.pointerId; this.canvas.classList.add("rw-cmap-canvas-panning"); const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; moveEvent.preventDefault(); this.canvas.scrollLeft = startScrollLeft - (moveEvent.clientX - startX); this.canvas.scrollTop = startScrollTop - (moveEvent.clientY - startY); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); this.canvas.classList.remove("rw-cmap-canvas-panning"); if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null; }; const cleanup = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); this.canvas.classList.remove("rw-cmap-canvas-panning"); if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null; }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); this.activeCanvasPanCleanup = cleanup; }; this.canvas.addEventListener("pointerdown", this.canvasPanPointerDownHandler); } handleMapSelection(component, event) { if (event.target instanceof Element && event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) { debug("cmap selection belongs to an editor control", elementDescription(event.target)); return; } const item = this.items.find((candidate) => candidate.node === component) || null; const connector = this.connectors.find((candidate) => candidate.link === component) || null; debug("selection callback received from cmap hit test", { componentFound: Boolean(component), itemId: item ? item.id : null, connectorId: connector ? connector.id : null, target: elementDescription(event.target) }); if (item) { const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey)); if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(item)) { this.editor.selectedItem = item; this.editor.refreshSelectionDecoration(); this.editor.notifySelection(); return; } this.editor.selectItem(item, { additive, toggle: additive }); return; } if (connector) { this.editor.selectConnector(connector); return; } if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.editor.clearSelection(); } handleMapActivation(component, event) { const item = this.items.find((candidate) => candidate.node === component) || null; debug("activation callback received from cmap", { itemId: item ? item.id : null, kind: item ? item.kind : null, pageSlug: item ? item.pageSlug : null }); if (!item) return; if (event && event.preventDefault) event.preventDefault(); if (item.kind === "phrase") { this.editor.editPhraseInline(item); return; } if (this.editor.onEditItem) { this.editor.selectItem(item); this.editor.onEditItem(item); } } startResize(event, record) { event.preventDefault(); event.stopPropagation(); record.autoWidth = false; record.autoHeight = false; record.fitContentPending = false; const startX = event.clientX; const startY = event.clientY; const startWidth = Number(record.node.attr("width")); const startHeight = Number(record.node.attr("height")); const pointerId = event.pointerId; event.currentTarget.setPointerCapture(pointerId); const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor)); record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor)); record.node.attr({ width: record.width, height: record.height }); record.node.redraw(); this.editor.decorateItem(record); this.editor.redrawConnectorsFor(record); this.editor.ensureCanvasExtent(Number(record.node.attr("x")) + record.width, Number(record.node.attr("y")) + record.height); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); this.editor.decorateItem(record); this.editor.scheduleHistoryCommit(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } startRelationDrag(event, source) { event.preventDefault(); event.stopPropagation(); const pointerId = event.pointerId; const start = this.editor.itemCenter(source); const draft = this.createDraftLine(start); this.editor.dragRelation = { source, draft }; event.currentTarget.setPointerCapture(pointerId); const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; const point = this.editor.canvasPoint(moveEvent); this.editor.ensureCanvasExtent(point.x, point.y); draft.line.setAttribute("x2", String(point.x)); draft.line.setAttribute("y2", String(point.y)); draft.svg.setAttribute("width", String(Math.max(this.editor.logicalCanvasWidth(), point.x + 180))); draft.svg.setAttribute("height", String(Math.max(this.editor.logicalCanvasHeight(), point.y + 180))); }; const up = (upEvent) => { if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); const target = this.editor.itemAt(upEvent.clientX, upEvent.clientY); const point = this.editor.canvasPoint(upEvent); draft.svg.remove(); this.editor.dragRelation = null; if (!target) { this.editor.ensureCanvasExtent(point.x, point.y); const parentSubmap = this.editor.submapAtPoint(point); debug("relation dropped on empty canvas", { sourceId: source.id, point, parentSubmapId: parentSubmap ? parentSubmap.id : null }); if (this.editor.onCreateConnectedItem) this.editor.onCreateConnectedItem({ source, point, parentSubmap }); return; } if (target === source) return; this.finishRelation(source, target, event.altKey || upEvent.altKey); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } finishRelation(source, target, direct = false) { if (source.kind === "phrase" && target.kind !== "phrase") { this.editor.addConnector(source, target, true); this.editor.reconcilePhraseMembership(source); this.editor.refreshSubmapVisibility(); this.editor.selectItem(source); return; } if (source.kind !== "phrase" && target.kind === "phrase") { this.editor.addConnector(source, target, false); this.editor.reconcilePhraseMembership(target); this.editor.refreshSubmapVisibility(); this.editor.selectItem(target); return; } if (source.kind === "phrase" && target.kind === "phrase") return; if (direct) { const connector = this.editor.addConnector(source, target, true); this.editor.refreshSubmapVisibility(); this.editor.selectConnector(connector); return; } this.editor.connectWithPhrase(source, target, "?????", true); } createDraftLine(start) { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.classList.add("rw-cmap-draft-layer"); svg.setAttribute("width", String(this.editor.logicalCanvasWidth())); svg.setAttribute("height", String(this.editor.logicalCanvasHeight())); const line = document.createElementNS(ns, "line"); line.setAttribute("x1", String(start.x)); line.setAttribute("y1", String(start.y)); line.setAttribute("x2", String(start.x)); line.setAttribute("y2", String(start.y)); line.setAttribute("class", "rw-cmap-draft-line"); svg.append(line); (this.editor.surfaceElement() || this.canvas).append(svg); return { svg, line }; } destroy() { if (this.marqueeMouseDownHandler) { this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler); this.marqueeMouseDownHandler = null; } if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); if (this.canvasPanPointerDownHandler) { this.canvas.removeEventListener("pointerdown", this.canvasPanPointerDownHandler); this.canvasPanPointerDownHandler = null; } if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup(); } }