/** * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace * @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode * @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor * @typedef {import('./WorkspaceInlineEditorManager.js').WorkspaceInlineEditorManager} WorkspaceInlineEditorManager */ import { WorkspaceGeometry } from "./WorkspaceGeometry.js"; /** * @typedef {"input"|"output"} PinDirection */ /** * Coordinates node DOM rendering, pin creation, and connection class updates. */ export class WorkspaceNodeRenderer { #workspace; #inlineEditors; #callbacks; /** * @param {BlueprintWorkspace} workspace Owning workspace instance. * @param {{ * inlineEditorManager: WorkspaceInlineEditorManager, * applySelectionState: () => void, * selectNode: (nodeId: string) => void, * startNodeDrag: (event: PointerEvent, nodeId: string) => void, * refreshVariableNode: (nodeId: string) => boolean, * refreshLocalVariableNode: (nodeId: string) => boolean, * refreshCustomEventNode: (nodeId: string) => boolean, * refreshCustomEventCallNode: (nodeId: string) => boolean, * renderConnections: () => void, * shouldHighlightPin: (nodeId: string, pinId: string, direction: PinDirection) => boolean, * beginConnection: (event: PointerEvent, nodeId: string, pinId: string, direction: PinDirection) => void, * finalizeConnection: (nodeId: string, pinId: string, direction: PinDirection) => void, * getPinElement: (nodeId: string, pinId: string, direction: PinDirection) => HTMLElement | null, * }} options Manager dependencies. */ constructor(workspace, options) { this.#workspace = workspace; this.#inlineEditors = options.inlineEditorManager; this.#callbacks = { applySelectionState: options.applySelectionState, selectNode: options.selectNode, startNodeDrag: options.startNodeDrag, refreshVariableNode: options.refreshVariableNode, refreshLocalVariableNode: options.refreshLocalVariableNode, refreshCustomEventNode: options.refreshCustomEventNode, refreshCustomEventCallNode: options.refreshCustomEventCallNode, renderConnections: options.renderConnections, shouldHighlightPin: options.shouldHighlightPin, beginConnection: options.beginConnection, finalizeConnection: options.finalizeConnection, getPinElement: options.getPinElement, }; } /** * Renders a node article, wires pin events, and updates inline editors. * * @param {BlueprintNode} node Target node. */ renderNode(node) { const fragment = this.#workspace.nodeTemplate.content.cloneNode(true); const article = /** @type {HTMLElement} */ ( fragment.querySelector(".blueprint-node") ); const title = /** @type {HTMLElement} */ ( article.querySelector(".node-title") ); const inputContainer = /** @type {HTMLElement} */ ( article.querySelector(".node-inputs") ); const outputContainer = /** @type {HTMLElement} */ ( article.querySelector(".node-outputs") ); title.textContent = node.title; article.dataset.nodeId = node.id; article.dataset.nodeType = node.type; article.style.transform = WorkspaceGeometry.positionToTransform( node.position ); this.#inlineEditors.setupLiteralEditor(node, article, inputContainer); inputContainer.innerHTML = ""; node.inputs.forEach((pin) => { const pinElement = this.#createPinElement(node, pin); pinElement.dataset.direction = "input"; inputContainer.appendChild(pinElement); this.#inlineEditors.setupPinEditor(node, pin, pinElement); }); outputContainer.innerHTML = ""; node.outputs.forEach((pin) => { const pinElement = this.#createPinElement(node, pin); pinElement.dataset.direction = "output"; outputContainer.appendChild(pinElement); this.#inlineEditors.setupPinEditor(node, pin, pinElement); }); article.addEventListener("pointerdown", (event) => { const target = /** @type {EventTarget | null} */ (event.target); if (!(target instanceof HTMLElement)) { return; } if (target.closest(".pin-handle")) { return; } const selectedIds = this.#workspace.selectedNodeIds; if (selectedIds.has(node.id)) { if (this.#workspace.selectedNodeId !== node.id) { this.#workspace.selectedNodeId = node.id; this.#callbacks.applySelectionState(); } } else { this.#callbacks.selectNode(node.id); } const isMouse = event.pointerType === "mouse" || event.pointerType === ""; if (isMouse && event.button !== 0) { return; } if ( !isMouse && event.pointerType !== "touch" && event.pointerType !== "pen" ) { return; } if (target.closest(".pin")) { return; } if (this.#isInteractiveElement(target)) { return; } this.#callbacks.startNodeDrag(event, node.id); }); article.addEventListener("pointerenter", () => { article.classList.add("is-hovered"); }); article.addEventListener("pointerleave", () => { article.classList.remove("is-hovered"); }); article.addEventListener("pointerdown", (event) => { if (event.button === 2) { this.#workspace.navigationManager?.beginPan(event); } }); article.addEventListener("contextmenu", (event) => { event.preventDefault(); event.stopPropagation(); if (this.#workspace.navigationManager?.isPanRecent(300)) { return; } const target = /** @type {EventTarget | null} */ (event.target); if (!(target instanceof HTMLElement)) { return; } if (target.closest(".pin")) { return; } if (!this.#workspace.selectedNodeIds.has(node.id)) { this.#callbacks.selectNode(node.id); } this.#workspace.nodeContextMenu.show(event.clientX, event.clientY, node.id); }); this.#workspace.nodeLayer.appendChild(article); this.#workspace.nodeElements.set(node.id, article); this.#inlineEditors.syncNode(node.id); let connectionsMutated = false; connectionsMutated = this.#callbacks.refreshVariableNode(node.id) || connectionsMutated; if (this.#callbacks.refreshLocalVariableNode) { connectionsMutated = this.#callbacks.refreshLocalVariableNode(node.id) || connectionsMutated; } if (node.type === "custom_event") { connectionsMutated = this.#callbacks.refreshCustomEventNode(node.id) || connectionsMutated; } else if (node.type === "call_custom_event") { connectionsMutated = this.#callbacks.refreshCustomEventCallNode(node.id) || connectionsMutated; } if (connectionsMutated) { this.#callbacks.renderConnections(); } this.updatePinConnectionsForNode(node.id); } /** * Rebuilds DOM pin containers for a node when its descriptors change. * * @param {BlueprintNode} node Target node. * @param {{ inputs?: boolean, outputs?: boolean }} [options] Pin refresh options. */ rebuildNodePins(node, options = {}) { const { inputs = true, outputs = true } = options; const article = this.#workspace.nodeElements.get(node.id); if (!article) { return; } if (inputs) { const inputContainer = article.querySelector(".node-inputs"); if (inputContainer instanceof HTMLElement) { inputContainer.innerHTML = ""; node.inputs.forEach((pin) => { const pinElement = this.#createPinElement(node, pin); pinElement.dataset.direction = "input"; inputContainer.appendChild(pinElement); this.#inlineEditors.setupPinEditor(node, pin, pinElement); }); } this.#inlineEditors.prunePinEditors(node); } if (outputs) { const outputContainer = article.querySelector(".node-outputs"); if (outputContainer instanceof HTMLElement) { outputContainer.innerHTML = ""; node.outputs.forEach((pin) => { const pinElement = this.#createPinElement(node, pin); pinElement.dataset.direction = "output"; outputContainer.appendChild(pinElement); this.#inlineEditors.setupPinEditor(node, pin, pinElement); }); } } this.updatePinConnectionsForNode(node.id); } /** * Applies connection state classes to every pin associated with a node. * * @param {string} nodeId Node identifier. */ updatePinConnectionsForNode(nodeId) { const node = this.#workspace.graph.nodes.get(nodeId); if (!node) { return; } node.inputs.forEach((pin) => { this.#applyPinConnectionState(nodeId, pin.id, "input"); }); node.outputs.forEach((pin) => { this.#applyPinConnectionState(nodeId, pin.id, "output"); }); } /** * Refreshes connection state classes for every pin in the workspace. */ refreshAllPinConnections() { this.#workspace.graph.nodes.forEach((node) => { this.updatePinConnectionsForNode(node.id); }); } /** * Creates and wires a pin element for the provided descriptor. * * @param {BlueprintNode} node Parent node reference. * @param {PinDescriptor} pin Pin descriptor. * @returns {HTMLElement} */ #createPinElement(node, pin) { const fragment = this.#workspace.pinTemplate.content.cloneNode(true); const container = /** @type {HTMLElement} */ (fragment.firstElementChild); const label = /** @type {HTMLElement} */ ( container.querySelector(".pin-label") ); const handle = /** @type {HTMLElement} */ ( container.querySelector(".pin-handle") ); container.dataset.pinId = pin.id; container.dataset.nodeId = node.id; container.dataset.type = pin.kind; const isStandardExec = pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out"); if (isStandardExec) { label.textContent = ""; label.classList.add("is-hidden"); } else { label.textContent = pin.name; } const shouldIgnoreForConnection = (target) => { if (!(target instanceof HTMLElement)) { return false; } return Boolean(target.closest(".pin-inline-control")); }; const handlePointerDown = (event) => { if (event.button !== 0) { return; } if (shouldIgnoreForConnection(event.target)) { return; } if (event.altKey) { event.preventDefault(); event.stopPropagation(); this.#workspace.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id, }); return; } event.stopPropagation(); event.preventDefault(); this.#callbacks.beginConnection(event, node.id, pin.id, pin.direction); }; const finalizeConnection = (event) => { if (shouldIgnoreForConnection(event.target)) { return; } if (!this.#workspace.pendingConnection) { return; } event.stopPropagation(); this.#callbacks.finalizeConnection(node.id, pin.id, pin.direction); }; handle.addEventListener("pointerdown", handlePointerDown); handle.addEventListener("pointerup", finalizeConnection); container.addEventListener("pointerdown", handlePointerDown); container.addEventListener("pointerup", finalizeConnection); container.addEventListener("pointerenter", () => { container.classList.add("is-hovered"); if ( this.#callbacks.shouldHighlightPin(node.id, pin.id, pin.direction) ) { container.classList.add("is-drop-hover"); } }); container.addEventListener("pointerleave", () => { container.classList.remove("is-hovered"); container.classList.remove("is-drop-hover"); }); const handleContextMenu = (event) => { if (shouldIgnoreForConnection(event.target)) { return; } event.preventDefault(); this.#workspace.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id, }); }; handle.addEventListener("contextmenu", handleContextMenu); container.addEventListener("contextmenu", handleContextMenu); this.#applyPinConnectionState(node.id, pin.id, pin.direction); return container; } /** * Applies connection state classes to the targeted pin element. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {PinDirection} direction Pin direction. */ #applyPinConnectionState(nodeId, pinId, direction) { const element = this.#callbacks.getPinElement(nodeId, pinId, direction); if (!element) { return; } const connected = this.#hasConnection(nodeId, pinId); element.classList.toggle("is-disconnected", !connected); element.classList.toggle("is-connected", connected); } /** * Determines whether a pin currently participates in any connection. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @returns {boolean} */ #hasConnection(nodeId, pinId) { return this.#workspace.graph .getConnectionsForNode(nodeId, pinId) .some((connection) => { const isTarget = connection.to.nodeId === nodeId && connection.to.pinId === pinId; const isSource = connection.from.nodeId === nodeId && connection.from.pinId === pinId; return isTarget || isSource; }); } /** * Determines whether a node event target should block drag initiation. * * @param {HTMLElement} element Candidate event target. * @returns {boolean} */ #isInteractiveElement(element) { if (element.closest(".pin-inline-control")) { return true; } if (element.closest(".literal-inline-editor")) { return true; } if (element.closest('[contenteditable="true"]')) { return true; } if (element.closest("input, textarea, select, button")) { return true; } return false; } }