/** * Debug overlay panel — top-right corner. * Displays live viewport info and exposes the draggable-items toggle. */ export class DebugPanel { /** @type {HTMLElement} */ #panel; /** @type {HTMLElement} */ #zoomEl; /** @type {HTMLElement} */ #dprEl; /** @type {HTMLElement | null} */ #viewportEl; /** @type {HTMLElement} */ #centerEl; /** @type {HTMLElement} */ #viewboxEl; /** @type {HTMLElement} */ #anchorEl; /** @type {HTMLInputElement} */ #draggableCheckbox; /** @type {HTMLElement} */ #activeItemRow; /** @type {HTMLElement} */ #activeItemLabel; /** @type {HTMLElement} */ #activeItemPos; /** @type {HTMLButtonElement} */ #copyBtn; /** @type {HTMLButtonElement} */ #copyJsonBtn; /** @type {HTMLButtonElement} */ #showPropertiesBtn; /** @type {HTMLElement} */ #nodeEditorDivider; /** @type {HTMLElement} */ #nodeEditor; /** @type {HTMLInputElement} */ #nodeIdInput; /** @type {HTMLInputElement} */ #nodeTypeInput; /** @type {HTMLInputElement} */ #nodeTitleInput; /** @type {HTMLInputElement} */ #nodeXInput; /** @type {HTMLInputElement} */ #nodeYInput; /** @type {HTMLInputElement} */ #nodeWidthInput; /** @type {HTMLElement} */ #inputsList; /** @type {HTMLElement} */ #outputsList; /** @type {HTMLButtonElement} */ #addInputBtn; /** @type {HTMLButtonElement} */ #addOutputBtn; /** @type {HTMLInputElement} */ #worldboxCheckbox; /** @type {HTMLInputElement} */ #viewboxCheckbox; /** @type {((enabled: boolean) => void) | null} */ #onDraggableChange = null; /** @type {((enabled: boolean) => void) | null} */ #onWorldBoxChange = null; /** @type {((enabled: boolean) => void) | null} */ #onViewboxChange = null; /** @type {(() => void) | null} */ #onCopyNode = null; /** @type {(() => void) | null} */ #onCopyGraph = null; /** @type {(() => void) | null} */ #onShowProperties = null; /** @type {((nodeId: string, updates: any) => void) | null} */ #onNodeUpdate = null; /** @type {string | null} */ #lastActiveId = null; /** * @param {HTMLElement} panel - The `.debug-panel` element. * @param {((enabled: boolean) => void) | null} [onDraggableChange] * @param {(() => void) | null} [onCopyNode] * @param {((nodeId: string, updates: any) => void) | null} [onNodeUpdate] * @param {((enabled: boolean) => void) | null} [onWorldBoxChange] * @param {(() => void) | null} [onCopyGraph] * @param {((enabled: boolean) => void) | null} [onViewboxChange] * @param {(() => void) | null} [onShowProperties] */ constructor(panel, onDraggableChange = null, onCopyNode = null, onNodeUpdate = null, onWorldBoxChange = null, onCopyGraph = null, onViewboxChange = null, onShowProperties = null) { this.#panel = panel; this.#onDraggableChange = onDraggableChange; this.#onCopyNode = onCopyNode; this.#onCopyGraph = onCopyGraph; this.#onNodeUpdate = onNodeUpdate; this.#onWorldBoxChange = onWorldBoxChange; this.#onViewboxChange = onViewboxChange; this.#onShowProperties = onShowProperties; this.#zoomEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-zoom")); this.#dprEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-dpr")); this.#viewportEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-viewport")); this.#centerEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-center")); this.#viewboxEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-viewbox")); this.#anchorEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-anchor")); this.#draggableCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-draggable-cb")); this.#activeItemRow = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-row")); this.#activeItemLabel = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-label")); this.#activeItemPos = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-pos")); this.#copyBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-copy-btn")); this.#copyJsonBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-copy-json-btn")); this.#showPropertiesBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-show-properties-btn")); this.#nodeEditorDivider = /** @type {HTMLElement} */ (document.getElementById("debugNodeEditorDivider")); this.#nodeEditor = /** @type {HTMLElement} */ (document.getElementById("debugNodeEditor")); this.#nodeIdInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-id")); this.#nodeTypeInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-type")); this.#nodeTitleInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-title")); this.#nodeXInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-x")); this.#nodeYInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-y")); this.#nodeWidthInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-width")); this.#inputsList = /** @type {HTMLElement} */ (document.getElementById("debugInputsList")); this.#outputsList = /** @type {HTMLElement} */ (document.getElementById("debugOutputsList")); this.#addInputBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-add-input-btn")); this.#addOutputBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-add-output-btn")); this.#worldboxCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-worldbox-cb")); this.#viewboxCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-viewbox-cb")); const toggleBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-toggle-btn")); toggleBtn?.addEventListener("click", () => { panel.classList.toggle("collapsed"); }); this.#draggableCheckbox.addEventListener("change", () => { this.#onDraggableChange?.(this.#draggableCheckbox.checked); }); this.#worldboxCheckbox?.addEventListener("change", () => { this.#onWorldBoxChange?.(this.#worldboxCheckbox.checked); }); this.#viewboxCheckbox?.addEventListener("change", () => { this.#onViewboxChange?.(this.#viewboxCheckbox.checked); }); this.#copyBtn.addEventListener("click", () => { this.#onCopyNode?.(); }); this.#copyJsonBtn?.addEventListener("click", () => { this.#onCopyGraph?.(); }); this.#showPropertiesBtn?.addEventListener("click", () => { this.#onShowProperties?.(); }); this.#nodeTitleInput.addEventListener("input", () => this.#handleNodeChange()); this.#nodeXInput.addEventListener("input", () => this.#handleNodeChange()); this.#nodeYInput.addEventListener("input", () => this.#handleNodeChange()); this.#nodeWidthInput.addEventListener("input", () => this.#handleNodeChange()); this.#addInputBtn.addEventListener("click", () => this.#handleAddPin("input")); this.#addOutputBtn.addEventListener("click", () => this.#handleAddPin("output")); } // ─── Public ─────────────────────────────────────────────────────────────── /** @returns {string | null} */ get lastActiveId() { return this.#lastActiveId; } /** * Sets the draggable checkbox to the given value without firing the change callback. * * @param {boolean} enabled */ setDraggable(enabled) { this.#draggableCheckbox.checked = enabled; } /** * Updates all displayed values. * * @param {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }} viewInfo * @param {string | null} [activeId] * @param {{ x: number, y: number, width: number, height: number } | null} [activeRect] * @param {any} [nodeData] - Full node data object for editor */ update(viewInfo, activeId = null, activeRect = null, nodeData = null) { const { zoom, center, viewbox } = viewInfo; this.#zoomEl.textContent = zoom.toFixed(3); this.#dprEl.textContent = (window.devicePixelRatio || 1).toFixed(2); // Use document.documentElement if window dimensions are 0 const vpWidth = window.innerWidth || document.documentElement.clientWidth || 0; const vpHeight = window.innerHeight || document.documentElement.clientHeight || 0; if (this.#viewportEl) { this.#viewportEl.textContent = `${vpWidth} × ${vpHeight}`; } this.#centerEl.textContent = `${this.#fmt(center.x)}, ${this.#fmt(center.y)}`; this.#viewboxEl.textContent = `${this.#fmt(viewbox.x)}, ${this.#fmt(viewbox.y)} ${this.#fmt(viewbox.width)} \u00d7 ${this.#fmt(viewbox.height)}`; if (activeRect && viewbox.width > 0 && viewbox.height > 0) { // 0 = node left/top edge at viewport left/top, 1 = node right/bottom edge at viewport right/bottom. const ax = (activeRect.x - viewbox.x) / Math.max(1, viewbox.width - activeRect.width); const ay = (activeRect.y - viewbox.y) / Math.max(1, viewbox.height - activeRect.height); this.#anchorEl.textContent = `${ax.toFixed(2)}, ${ay.toFixed(2)}`; } else { this.#anchorEl.textContent = '\u2014'; } if (activeId && activeRect) { this.#lastActiveId = activeId; this.#activeItemRow.hidden = false; this.#activeItemLabel.textContent = activeId; this.#activeItemPos.textContent = `${this.#fmt(activeRect.x)}, ${this.#fmt(activeRect.y)}`; if (nodeData) { this.#populateNodeEditor(nodeData); } } else { this.#activeItemRow.hidden = true; this.#hideNodeEditor(); } } /** * Populates node editor with node data. * * @param {any} node */ #populateNodeEditor(node) { this.#nodeEditorDivider.hidden = false; this.#nodeEditor.hidden = false; this.#nodeIdInput.value = node.id || ''; this.#nodeTypeInput.value = node.type || ''; this.#nodeTitleInput.value = node.title || ''; this.#nodeXInput.value = String(node.position?.x ?? 0); this.#nodeYInput.value = String(node.position?.y ?? 0); this.#nodeWidthInput.value = node.width != null ? String(node.width) : ''; this.#renderPinsList(node.inputs || [], "input"); this.#renderPinsList(node.outputs || [], "output"); } /** * Renders pin list. * * @param {any[]} pins * @param {('input'|'output')} direction */ #renderPinsList(pins, direction) { const container = direction === "input" ? this.#inputsList : this.#outputsList; container.innerHTML = ''; pins.forEach((pin, index) => { const pinEl = document.createElement("div"); pinEl.className = "debug-pin-item"; pinEl.innerHTML = `
`; pinEl.querySelectorAll('.debug-pin-input').forEach(input => { input.addEventListener('input', () => this.#handlePinChange(direction)); input.addEventListener('change', () => this.#handlePinChange(direction)); }); const removeBtn = pinEl.querySelector('.debug-pin-remove'); removeBtn?.addEventListener('click', () => this.#handleRemovePin(direction, index)); container.appendChild(pinEl); }); } /** * Hides node editor. */ #hideNodeEditor() { this.#nodeEditorDivider.hidden = true; this.#nodeEditor.hidden = true; } /** * Handles node property changes. */ #handleNodeChange() { if (!this.#lastActiveId || !this.#onNodeUpdate) return; const updates = { title: this.#nodeTitleInput.value, position: { x: parseFloat(this.#nodeXInput.value) || 0, y: parseFloat(this.#nodeYInput.value) || 0 } }; const widthVal = this.#nodeWidthInput.value.trim(); if (widthVal !== '') { updates.width = parseFloat(widthVal) || null; } this.#onNodeUpdate(this.#lastActiveId, updates); } /** * Handles pin property changes. * * @param {('input'|'output')} direction */ #handlePinChange(direction) { if (!this.#lastActiveId || !this.#onNodeUpdate) return; const container = direction === "input" ? this.#inputsList : this.#outputsList; const pins = []; container.querySelectorAll('.debug-pin-item').forEach(pinEl => { const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id')); const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name')); const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind')); const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default')); const pin = { id: idInput.value || `pin_${Date.now()}`, name: nameInput.value || '', kind: kindSelect.value, direction: direction }; if (defaultInput.value.trim() !== '') { pin.defaultValue = defaultInput.value; } pins.push(pin); }); this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins }); } /** * Handles adding a new pin. * * @param {('input'|'output')} direction */ #handleAddPin(direction) { if (!this.#lastActiveId || !this.#onNodeUpdate) return; const container = direction === "input" ? this.#inputsList : this.#outputsList; const newPin = { id: `pin_${Date.now()}`, name: '', kind: 'exec', direction: direction }; // Get existing pins and add new one const pins = []; container.querySelectorAll('.debug-pin-item').forEach(pinEl => { const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id')); const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name')); const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind')); const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default')); const pin = { id: idInput.value, name: nameInput.value, kind: kindSelect.value, direction: direction }; if (defaultInput.value.trim() !== '') { pin.defaultValue = defaultInput.value; } pins.push(pin); }); pins.push(newPin); this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins }); } /** * Handles removing a pin. * * @param {('input'|'output')} direction * @param {number} index */ #handleRemovePin(direction, index) { if (!this.#lastActiveId || !this.#onNodeUpdate) return; const container = direction === "input" ? this.#inputsList : this.#outputsList; const pins = []; container.querySelectorAll('.debug-pin-item').forEach((pinEl, i) => { if (i === index) return; // Skip removed pin const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id')); const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name')); const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind')); const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default')); const pin = { id: idInput.value, name: nameInput.value, kind: kindSelect.value, direction: direction }; if (defaultInput.value.trim() !== '') { pin.defaultValue = defaultInput.value; } pins.push(pin); }); this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins }); } // ─── Private ────────────────────────────────────────────────────────────── /** * Formats a world-space number to a compact fixed-point string. * * @param {number} n * @returns {string} */ #fmt(n) { return n.toFixed(1); } }