Files
lit.ruv.wtf/website/scripts/NodeRenderer.js

762 lines
27 KiB
JavaScript

import { getTypeColor } from "./getTypeColor.js";
import { MarkdownRenderer } from "./MarkdownRenderer.js";
import { NodeRegistry, NodeRenderContext } from "./nodes/index.js";
/**
* @typedef {import('./GraphNode.js').GraphNode} GraphNode
* @typedef {import('./GraphNode.js').PinDescriptor} PinDescriptor
* @typedef {import('./GraphConnection.js').GraphConnection} GraphConnection
* @typedef {import('./GraphComment.js').GraphComment} GraphComment
*/
/**
* Renders static blueprint nodes into a DOM layer.
* Stripped-down port of WorkspaceNodeRenderer — no drag, no selection, no context menus.
*/
export class NodeRenderer {
/** @type {HTMLElement} */
#nodeLayer;
/** @type {HTMLTemplateElement} */
#nodeTemplate;
/** @type {HTMLTemplateElement} */
#pinTemplate;
/** @type {Map<string, GraphNode>} */
#nodes = new Map();
/** @type {Map<string, HTMLElement>} */
#nodeElements = new Map();
/** @type {Map<string, GraphConnection>} */
#connections = new Map();
/** @type {Map<string, GraphComment>} */
#comments = new Map();
/** @type {Map<string, HTMLElement>} */
#commentElements = new Map();
/** @type {HTMLElement | null} */
#commentLayer = null;
/**
* Active interval handles keyed by timer node ID.
*
* @type {Map<string, number>}
*/
#timerIntervals = new Map();
/** @type {((nodeId: string, pinId: string, direction: string) => void) | null} */
#onNodeClick = null;
/** @type {((nodeId: string, pinId?: string) => void) | null} */
#onNodeExecute = null;
/** @type {(() => void) | null} */
#onConnectionRemoved = null;
/**
* Promises for all async markdown loads, used by contentReady().
*
* @type {Promise<void>[]}
*/
#markdownPromises = [];
/**
* Active chat node instances keyed by node ID.
*
* @type {Map<string, import('./ChatNode.js').ChatNode>}
*/
#chatInstances = new Map();
/** @type {NodeRenderContext | null} */
#renderCtx = null;
/**
* @param {HTMLElement} nodeLayer
* @param {HTMLTemplateElement} nodeTemplate
* @param {HTMLTemplateElement} pinTemplate
* @param {((nodeId: string, pinId: string, direction: string) => void) | null} [onNodeClick]
* @param {((nodeId: string, pinId?: string) => void) | null} [onNodeExecute]
* @param {(() => void) | null} [onConnectionRemoved]
*/
constructor(nodeLayer, nodeTemplate, pinTemplate, onNodeClick = null, onNodeExecute = null, onConnectionRemoved = null) {
this.#nodeLayer = nodeLayer;
this.#nodeTemplate = nodeTemplate;
this.#pinTemplate = pinTemplate;
this.#onNodeClick = onNodeClick;
this.#onNodeExecute = onNodeExecute;
this.#onConnectionRemoved = onConnectionRemoved;
this.#renderCtx = new NodeRenderContext({
loadMarkdown: (src, target) => this.#loadMarkdown(src, target),
addMarkdownPromise: (p) => this.#markdownPromises.push(p),
onNodeExecute: (nodeId, pinId) => this.#onNodeExecute?.(nodeId, pinId),
registerChatInstance: (nodeId, instance) => this.#chatInstances.set(nodeId, instance),
startTimer: (nodeId, toggle) => this.#startTimer(nodeId, toggle),
stopTimer: (nodeId) => this.#stopTimer(nodeId),
svgPlay: () => NodeRenderer.#svgPlay(),
svgStop: () => NodeRenderer.#svgStop(),
resolveInputValue: (nodeId, pinId) => this.#resolveInputValue(nodeId, pinId),
});
}
// ─── Public ───────────────────────────────────────────────────────────────
/**
* Adds a node to the graph and renders it into the DOM.
*
* @param {GraphNode} node
*/
addNode(node) {
this.#nodes.set(node.id, node);
this.#renderNode(node);
}
/**
* Removes a node from the graph and DOM.
*
* @param {string} nodeId
*/
removeNode(nodeId) {
const element = this.#nodeElements.get(nodeId);
if (element) {
element.remove();
this.#nodeElements.delete(nodeId);
}
// Remove all connections touching this node
const connIds = [];
for (const [id, conn] of this.#connections) {
if (conn.from.nodeId === nodeId || conn.to.nodeId === nodeId) {
connIds.push(id);
}
}
for (const id of connIds) {
this.#connections.delete(id);
}
// Stop any active timers
const intervalHandle = this.#timerIntervals.get(nodeId);
if (intervalHandle !== undefined) {
clearInterval(intervalHandle);
this.#timerIntervals.delete(nodeId);
}
// Clean up chat instances
const chatInstance = this.#chatInstances.get(nodeId);
if (chatInstance) {
this.#chatInstances.delete(nodeId);
}
this.#nodes.delete(nodeId);
// Notify connections need refresh
if (connIds.length > 0) {
this.#onConnectionRemoved?.();
}
}
/**
* Adds a comment box to the graph and renders it into the DOM.
*
* @param {GraphComment} comment
*/
addComment(comment) {
this.#comments.set(comment.id, comment);
this.#renderComment(comment);
}
/**
* Removes all connections touching a specific pin and refreshes pin state.
* Calls onConnectionRemoved if any were removed.
*
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
*/
disconnectPin(nodeId, pinId, direction) {
let removed = false;
for (const [id, conn] of this.#connections) {
const matches = direction === "output"
? conn.from.nodeId === nodeId && conn.from.pinId === pinId
: conn.to.nodeId === nodeId && conn.to.pinId === pinId;
if (matches) {
const otherNodeId = direction === "output" ? conn.to.nodeId : conn.from.nodeId;
this.#connections.delete(id);
this.#refreshPinStates(nodeId);
this.#refreshPinStates(otherNodeId);
removed = true;
}
}
if (removed) this.#onConnectionRemoved?.();
}
/**
* Adds a connection with kind-aware deduplication:
* - Exec pins: one-out (only one connection may leave a given exec output), many-in allowed.
* - Data pins: many-out allowed, one-in (only one connection may enter a given data input).
*
* @param {GraphConnection} connection
*/
addConnection(connection) {
/** @type {Set<string>} */
const staleNodes = new Set();
for (const [id, existing] of this.#connections) {
const isDuplicate = connection.kind === "exec"
? existing.from.nodeId === connection.from.nodeId && existing.from.pinId === connection.from.pinId
: existing.to.nodeId === connection.to.nodeId && existing.to.pinId === connection.to.pinId;
if (isDuplicate) {
staleNodes.add(existing.from.nodeId);
staleNodes.add(existing.to.nodeId);
this.#connections.delete(id);
break;
}
}
this.#connections.set(connection.id, connection);
staleNodes.add(connection.from.nodeId);
staleNodes.add(connection.to.nodeId);
for (const nodeId of staleNodes) this.#refreshPinStates(nodeId);
}
/**
* Returns all current connections.
*
* @returns {GraphConnection[]}
*/
getConnections() {
return [...this.#connections.values()];
}
/**
* Returns all current nodes.
*
* @returns {GraphNode[]}
*/
getNodes() {
return [...this.#nodes.values()];
}
/**
* Returns the world-space bounding rect of a node using its position and DOM size.
*
* @param {string} nodeId
* @returns {{ x: number, y: number, width: number, height: number } | null}
*/
/**
* Returns a promise that resolves once all markdown content has finished loading.
* Uses allSettled so a single failed fetch does not block others.
*
* @returns {Promise<void>}
*/
contentReady() {
return Promise.allSettled(this.#markdownPromises).then(() => {});
}
getNodeWorldRect(nodeId) {
const node = this.#nodes.get(nodeId);
const el = this.#nodeElements.get(nodeId);
if (!node || !el) return null;
return {
x: node.position.x,
y: node.position.y,
width: el.offsetWidth || 220,
height: el.offsetHeight || 80,
};
}
/**
* Returns the first node connected to the given node (via any connection).
*
* @param {string} nodeId
* @returns {string | null} The other node's ID, or null.
*/
getFirstConnectedNodeId(nodeId) {
for (const conn of this.#connections.values()) {
if (conn.from.nodeId === nodeId) return conn.to.nodeId;
if (conn.to.nodeId === nodeId) return conn.from.nodeId;
}
return null;
}
/**
* Returns the node ID connected to a specific pin.
*
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
* @returns {string | null}
*/
getConnectedNodeId(nodeId, pinId, direction) {
for (const conn of this.#connections.values()) {
if (direction === "output" && conn.from.nodeId === nodeId && conn.from.pinId === pinId) return conn.to.nodeId;
if (direction === "input" && conn.to.nodeId === nodeId && conn.to.pinId === pinId) return conn.from.nodeId;
}
return null;
}
/**
* Returns the DOM element for a node.
*
* @param {string} nodeId
* @returns {HTMLElement | undefined}
*/
getNodeElement(nodeId) {
return this.#nodeElements.get(nodeId);
}
/**
* Returns the pin handle element for use in connection geometry calculations.
*
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
* @returns {HTMLElement | null}
*/
getPinHandle(nodeId, pinId, direction) {
const article = this.#nodeElements.get(nodeId);
if (!article) return null;
const container = direction === "input"
? article.querySelector(".node-inputs")
: article.querySelector(".node-outputs");
if (!container) return null;
const pin = container.querySelector(`[data-pin-id="${pinId}"]`);
return pin ? pin.querySelector(".pin-handle") : null;
}
/**
* Updates a node's world-space position and its DOM transform, with optional rotation for sway.
* Rotation formula matches WorkspaceGeometry.positionToTransform in Picograph.
*
* @param {string} nodeId
* @param {number} x
* @param {number} y
* @param {number} [rotation] - Z-rotation in degrees; also produces a perspective Y-axis lean.
*/
setNodeTransform(nodeId, x, y, rotation = 0) {
const node = this.#nodes.get(nodeId);
const el = this.#nodeElements.get(nodeId);
if (!node || !el) return;
node.position.x = x;
node.position.y = y;
el.style.transform = NodeRenderer.#buildTransform(x, y, rotation);
}
/**
* Updates a node's world-space position (no rotation).
*
* @param {string} nodeId
* @param {number} x
* @param {number} y
*/
setNodePosition(nodeId, x, y) {
this.setNodeTransform(nodeId, x, y, 0);
}
/**
* Builds a CSS transform string from a world position and optional rotation.
*
* @param {number} x
* @param {number} y
* @param {number} rotation
* @returns {string}
*/
static #buildTransform(x, y, rotation) {
if (!rotation) return `translate3d(${x}px, ${y}px, 0)`;
return `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg)`;
}
/**
* Returns all rendered node elements keyed by node ID.
*
* @returns {Map<string, HTMLElement>}
*/
getNodeElements() {
return this.#nodeElements;
}
// ─── Rendering ────────────────────────────────────────────────────────────
/** @param {GraphNode} node */
#renderNode(node) {
const fragment = /** @type {DocumentFragment} */ (this.#nodeTemplate.content.cloneNode(true));
const article = /** @type {HTMLElement} */ (fragment.querySelector(".blueprint-node"));
const title = /** @type {HTMLElement} */ (article.querySelector(".node-title"));
const inputs = /** @type {HTMLElement} */ (article.querySelector(".node-inputs"));
const outputs = /** @type {HTMLElement} */ (article.querySelector(".node-outputs"));
article.dataset.nodeId = node.id;
article.dataset.nodeType = node.type;
title.textContent = node.title;
article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`;
if (node.width != null) article.style.width = `${node.width}px`;
inputs.innerHTML = "";
outputs.innerHTML = "";
node.inputs.forEach(pin => {
const el = this.#createPinElement(node.id, pin, "input");
inputs.appendChild(el);
});
node.outputs.forEach(pin => {
const el = this.#createPinElement(node.id, pin, "output");
outputs.appendChild(el);
});
// Dispatch type-specific rendering to the registered node handler
const handler = NodeRegistry.BlueprintPure_Get(node.type);
if (handler) {
handler.BlueprintNativeEvent_OnRender(article, node, this.#renderCtx);
}
this.#nodeLayer.appendChild(article);
this.#nodeElements.set(node.id, article);
}
/**
* Fetches a markdown file and renders it into the target element.
*
* @param {string} src - Relative URL to the .md file.
* @param {HTMLElement} target - Container to populate.
* @returns {Promise<void>}
*/
async #loadMarkdown(src, target) {
try {
const response = await fetch(src);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
target.innerHTML = MarkdownRenderer.render(text);
MarkdownRenderer.applyImageScale(target);
} catch (err) {
target.innerHTML = `<p class="md-error">Failed to load content.</p>`;
console.error(`[NodeRenderer] Could not load markdown "${src}":`, err);
}
}
/**
* Renders a comment box into the DOM.
*
* @param {GraphComment} comment
*/
#renderComment(comment) {
// Create comment layer if it doesn't exist
if (!this.#commentLayer) {
this.#commentLayer = document.createElement("div");
this.#commentLayer.className = "comment-layer";
this.#nodeLayer.insertBefore(this.#commentLayer, this.#nodeLayer.firstChild);
}
const box = document.createElement("div");
box.className = "blueprint-comment";
box.dataset.commentId = comment.id;
box.style.transform = `translate3d(${comment.position.x}px, ${comment.position.y}px, 0)`;
box.style.width = `${comment.size.width}px`;
box.style.height = `${comment.size.height}px`;
// Create the colored background layer
const bg = document.createElement("div");
bg.className = "comment-background";
bg.style.backgroundColor = comment.color;
bg.style.opacity = comment.opacity.toString();
box.appendChild(bg);
const titleBar = document.createElement("div");
titleBar.className = "comment-title";
titleBar.textContent = comment.title;
box.appendChild(titleBar);
this.#commentLayer.appendChild(box);
this.#commentElements.set(comment.id, box);
}
// ─── Timer ────────────────────────────────────────────────────────────────
/**
* Starts the interval loop for a timer node, updating toggle button state.
*
* @param {string} nodeId
* @param {HTMLButtonElement} toggle
*/
#startTimer(nodeId, toggle) {
this.#stopTimer(nodeId);
const node = this.#nodes.get(nodeId);
const intervalPin = node?.inputs.find(p => p.id === "interval");
const seconds = Math.max(0.1, Math.min(10, parseFloat(intervalPin?.defaultValue ?? "1") || 1));
toggle.dataset.running = "true";
toggle.innerHTML = NodeRenderer.#svgStop();
const handle = window.setInterval(() => {
this.#onNodeExecute?.(nodeId);
}, seconds * 1000);
this.#timerIntervals.set(nodeId, handle);
}
/**
* Stops a running timer and resets the toggle button state.
*
* @param {string} nodeId
*/
#stopTimer(nodeId) {
const handle = this.#timerIntervals.get(nodeId);
if (handle != null) {
window.clearInterval(handle);
this.#timerIntervals.delete(nodeId);
}
const el = this.#nodeElements.get(nodeId);
const toggle = /** @type {HTMLButtonElement | null} */ (el?.querySelector(".node-timer-btn"));
if (toggle) {
toggle.dataset.running = "false";
toggle.innerHTML = NodeRenderer.#svgPlay();
toggle.title = "Start timer";
}
}
/** @returns {string} */
static #svgPlay() {
return `<svg width="9" height="9" viewBox="0 0 9 9" fill="currentColor" aria-hidden="true"><polygon points="1,0 9,4.5 1,9"/></svg>`;
}
/** @returns {string} */
static #svgStop() {
return `<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="8" height="8" rx="1"/></svg>`;
}
/**
* Resolves input value by following connections (for render-time use).
*
* @param {string} nodeId
* @param {string} pinId
* @returns {any}
*/
#resolveInputValue(nodeId, pinId) {
try {
console.log('[NodeRenderer#resolveInputValue] Looking for', nodeId, pinId);
console.log('[NodeRenderer#resolveInputValue] Available connections:', this.#connections);
console.log('[NodeRenderer#resolveInputValue] Available nodes:', Array.from(this.#nodes.keys()));
const conn = Array.from(this.#connections.values()).find(
c => c.to.nodeId === nodeId && c.to.pinId === pinId
);
console.log('[NodeRenderer#resolveInputValue] Found connection:', conn);
if (conn) {
const sourceNode = this.#nodes.get(conn.from.nodeId);
console.log('[NodeRenderer] Resolving', nodeId, pinId, 'from', conn.from.nodeId, 'sourceNode:', sourceNode);
// Handle random_name node
if (sourceNode?.type === "random_name" && conn.from.pinId === "name") {
console.log('[NodeRenderer] Random name value:', sourceNode._generatedName);
return sourceNode._generatedName || "";
}
// Fallback to source pin defaultValue
const sourcePin = sourceNode?.getPin?.(conn.from.pinId);
if (sourcePin?.defaultValue != null) {
return sourcePin.defaultValue;
}
}
// No connection, use local pin defaultValue
const node = this.#nodes.get(nodeId);
const pin = node?.getPin?.(pinId);
return pin?.defaultValue ?? "";
} catch (err) {
console.error(`Failed to resolve input ${nodeId}.${pinId}:`, err);
return "";
}
}
/**
* @param {string} nodeId
* @param {PinDescriptor} pin
* @param {('input'|'output')} direction
* @returns {HTMLElement}
*/
#createPinElement(nodeId, pin, direction) {
const fragment = /** @type {DocumentFragment} */ (this.#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.type = pin.kind;
container.dataset.direction = direction;
container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind));
// Exec and string pins are clickable: focus the connected node
if (this.#onNodeClick && (pin.kind === "exec" || pin.kind === "string")) {
container.classList.add("is-clickable");
/** @param {MouseEvent} e */
const handleClick = (e) => {
e.stopPropagation();
if (e.altKey) return;
if (container.classList.contains("is-disconnected")) return;
this.#onNodeClick(nodeId, pin.id, direction);
};
handle.addEventListener("click", handleClick);
label.addEventListener("click", handleClick);
}
// Alt+click on any pin handle disconnects it
handle.addEventListener("click", (e) => {
if (!e.altKey) return;
e.stopPropagation();
this.disconnectPin(nodeId, pin.id, direction);
});
const isStandardExec = pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
if (isStandardExec && !pin.name) {
label.textContent = "";
label.classList.add("is-hidden");
} else {
label.textContent = pin.name;
}
// String pins: render an editable inline value chip
// For outputs: only on pure/string nodes
// For inputs: always show if there's a defaultValue
const node = this.#nodes.get(nodeId);
const isPureOrStringNode = node && (node.type === "pure" || node.type === "string");
const shouldShowStringValue = pin.kind === "string" && pin.defaultValue != null &&
((direction === "output" && isPureOrStringNode) || direction === "input");
if (shouldShowStringValue) {
const valueChip = document.createElement("span");
valueChip.className = "pin-value";
valueChip.textContent = pin.defaultValue;
valueChip.contentEditable = "true";
valueChip.spellcheck = false;
// Prevent workspace pan/drag from stealing pointer events while editing
valueChip.addEventListener("pointerdown", (e) => e.stopPropagation());
// Prevent pin click navigation from firing when clicking into the chip
valueChip.addEventListener("click", (e) => e.stopPropagation());
// Keep caret inside on Enter instead of inserting a <br>
valueChip.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
valueChip.blur();
}
});
// Sync back to the live pin descriptor so GraphExecutor reads the new value
valueChip.addEventListener("input", () => {
pin.defaultValue = valueChip.textContent ?? "";
});
// Clean up any accidental HTML on blur
valueChip.addEventListener("blur", () => {
const text = valueChip.textContent ?? "";
pin.defaultValue = text;
valueChip.textContent = text;
});
container.appendChild(valueChip);
}
// Number pins with defaultValue: render a clamped numeric input on inputs only
if (pin.kind === "number" && pin.defaultValue != null && direction === "input") {
const numInput = document.createElement("input");
numInput.type = "number";
numInput.className = "pin-value pin-value--number";
numInput.value = pin.defaultValue;
if (pin.min != null) numInput.min = String(pin.min);
if (pin.max != null) numInput.max = String(pin.max);
numInput.step = "0.1";
numInput.addEventListener("pointerdown", (e) => e.stopPropagation());
numInput.addEventListener("click", (e) => e.stopPropagation());
const clampAndSync = () => {
let v = parseFloat(numInput.value);
if (!Number.isFinite(v)) v = parseFloat(pin.defaultValue ?? "1");
if (pin.min != null) v = Math.max(pin.min, v);
if (pin.max != null) v = Math.min(pin.max, v);
const str = String(Math.round(v * 100) / 100);
numInput.value = str;
pin.defaultValue = str;
};
numInput.addEventListener("change", clampAndSync);
numInput.addEventListener("blur", clampAndSync);
container.appendChild(numInput);
}
// Start disconnected — addConnection() will update these
container.classList.add("is-disconnected");
return container;
}
/**
* Re-evaluates connected/disconnected classes for all pins on a node.
*
* @param {string} nodeId
*/
#refreshPinStates(nodeId) {
const node = this.#nodes.get(nodeId);
if (!node) return;
[...node.inputs, ...node.outputs].forEach(pin => {
this.#applyPinState(nodeId, pin.id, pin.direction);
});
}
/**
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
*/
#applyPinState(nodeId, pinId, direction) {
const handle = this.getPinHandle(nodeId, pinId, direction);
const pin = this.#findPinContainer(nodeId, pinId, direction);
if (!pin) return;
const connected = [...this.#connections.values()].some(c =>
(c.from.nodeId === nodeId && c.from.pinId === pinId) ||
(c.to.nodeId === nodeId && c.to.pinId === pinId)
);
pin.classList.toggle("is-connected", connected);
pin.classList.toggle("is-disconnected", !connected);
// Hide inline value chip on input pins when connected
if (direction === "input") {
const chip = pin.querySelector(".pin-value");
if (chip) chip.style.display = connected ? "none" : "";
}
}
/**
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
* @returns {HTMLElement | null}
*/
#findPinContainer(nodeId, pinId, direction) {
const article = this.#nodeElements.get(nodeId);
if (!article) return null;
const section = direction === "input"
? article.querySelector(".node-inputs")
: article.querySelector(".node-outputs");
return section?.querySelector(`[data-pin-id="${pinId}"]`) ?? null;
}
}