From 34c2aa4316a7abcb5b3dcb0812bf097698866a91 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 19 Apr 2026 05:45:33 +1000 Subject: [PATCH] can now add nodes --- website/data/denied.mp3 | Bin 0 -> 3590 bytes website/scripts/GraphNode.js | 4 +- website/scripts/ItemDragger.js | 17 + website/scripts/NodeContextMenu.js | 347 +++++++++++++++++++++ website/scripts/NodeRenderer.js | 45 +++ website/scripts/WorkspaceNavigator.js | 11 + website/scripts/main.js | 102 ++++++ website/scripts/nodes/BindEventNode.js | 15 + website/scripts/nodes/ButtonNode.js | 12 + website/scripts/nodes/ChatConnectNode.js | 16 + website/scripts/nodes/CustomEventNode.js | 12 + website/scripts/nodes/GetMatrixChatNode.js | 12 + website/scripts/nodes/InfoNode.js | 10 + website/scripts/nodes/LottieNode.js | 10 + website/scripts/nodes/MatrixChatNode.js | 16 + website/scripts/nodes/NodeBase.js | 11 + website/scripts/nodes/NodeRegistry.js | 15 + website/scripts/nodes/PrintNode.js | 15 + website/scripts/nodes/PureNode.js | 12 + website/scripts/nodes/RandomNameNode.js | 37 +++ website/scripts/nodes/SequenceNode.js | 15 + website/scripts/nodes/TimerNode.js | 14 + website/styles/main.css | 117 +++++++ 23 files changed, 864 insertions(+), 1 deletion(-) create mode 100644 website/data/denied.mp3 create mode 100644 website/scripts/NodeContextMenu.js diff --git a/website/data/denied.mp3 b/website/data/denied.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ff82d6d74d0bcd0fc7077f549e05c81900f79bd2 GIT binary patch literal 3590 zcmd^>c~DbV6vj_@2_$UNB8z1U5+p#tAQnRr3=xn;1e~VgQVl`{S!^t7vD3~x2vJZ_ zKt$A*rc-eOq7xmMQibVDj5xKTK&izMEFeXsZWI>;YI>h7VQgfv{MG3^Eiu5&7RaI3rQXSF-q{ha^Ye+YddV6~xAU#FWXf%jrxI8d2*xS>K%cX{nZOMYk z(mxjnlq2Lmn7{vGKQ{1xd1^+?)PNB{Iff!;0xVJCK&6`m69s@hKW2XKz^GEexg1Y# z09&=i;OIrAbq38>#)}yJ$mqxVYNEaqfHHWp%QQ9WJL?Fen#SaT$9NJvWg0$2etavD zf5j}b%tS}A1{H!UPHmqrINS`7AoV;~#-s*%fx|w`9@GM5LQQZ%F7wJ`bGkN5!l?uu z4~+^C1{bW)Nh#TPGt~(O`uRqUB1_n%zb4?;@ptF!_K36;PT%n*B2Y3#{P-t+W&00F zjN1Sy<`ZP(rxnk#D)tndrzqu_8LR z>u`ENS?#_F_h&13w6qMY-gumIWfbb#2(*$Ey*B!h%FoZq$y5?(mh(s7+#7En+VEuf zdl#Pq2ON!Kte!9#=RG)YW4=GtOCUlE^K)w zo_|U&zqKF7unwHMr!c5#xxKusZH<;g)+KpvJy|NY*ZYJ=9-E`j8G`cQsSB1-K~<0; zcBXb(SuM7M1tm6--azGv0nWq}h042#8+&v({aI@3WkT8Fi)=e-ashBWrsj;zfHf9i zYhF@XZIWf8hIz$Iu#NB<{no+RvH0cEBICKKM_hJa3-FcLS(?hDsxQDk)t&UTWpR~dqyj@1+$K-mr4*l^Jg za|S%kIxT9g4W)a}yX$Zs0kzg;`*m3Xtj6w*lmc}M+gA*u3YW#Y; zrny0f^DD0Gu}Q*doxA|-?hvg``U)e|1qO-C-Kc&?pDt|*DsyK8`L^D$N-c*>CV6gspgQKTfjHeQ^OfA=VT-fJvaI_oHS;+lLv>c~ zv7==ey4KYkELo?bNe-q^eQ-!)h#XHt9tZ1(Wzx>Ku)m^fB4l zm9;c46W#Ngqz4%KFJ9T&tp~%n#@9GoK9gV4;dCO7M>xJPS;UkkiwyEtOyc^F zA)t#1GN|m&2Qq}+%?^A&R^BK4QH>W$`7~!jAvrJRe26y zdlqf{3#Os3C;t8+rBp=Cd z83u~H@wqc;>2SI;m929U39p_c7fEUca7||7KEc|*-cyyaLmcABI9Qb##@aW*+?jNf zoCk2e$vs|Oo-JQNy%X(C?U;$-1ZzpoHS)5IZbjr}c_EH&UlYuoNw=2s_oe*5&iNC3 CH5M=c literal 0 HcmV?d00001 diff --git a/website/scripts/GraphNode.js b/website/scripts/GraphNode.js index 6471269..768a185 100644 --- a/website/scripts/GraphNode.js +++ b/website/scripts/GraphNode.js @@ -14,7 +14,7 @@ */ export class GraphNode { /** - * @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init + * @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, userSpawned?: boolean, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init */ constructor(init) { this.id = init.id; @@ -31,6 +31,8 @@ export class GraphNode { this.imageSrc = init.imageSrc; /** @type {string | undefined} */ this.lottieSrc = init.lottieSrc; + /** @type {boolean | undefined} */ + this.userSpawned = init.userSpawned; /** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */ this.focusOptions = init.focusOptions; } diff --git a/website/scripts/ItemDragger.js b/website/scripts/ItemDragger.js index 4800240..3bda38d 100644 --- a/website/scripts/ItemDragger.js +++ b/website/scripts/ItemDragger.js @@ -680,6 +680,23 @@ export class ItemDragger { this.#onActiveItemChange?.(id, pos); } + /** + * Unregisters a node that's being deleted. + * + * @param {string} nodeId + */ + unregisterNode(nodeId) { + this.#itemPositions.delete(nodeId); + this.#applyTransform.delete(nodeId); + this.#dragRotation.delete(nodeId); + this.#dragOrigin.delete(nodeId); + + // Clear active if it's the deleted node + if (this.#activeId === nodeId) { + this.clearSelection(); + } + } + /** * Clear the current selection */ diff --git a/website/scripts/NodeContextMenu.js b/website/scripts/NodeContextMenu.js new file mode 100644 index 0000000..2a022c9 --- /dev/null +++ b/website/scripts/NodeContextMenu.js @@ -0,0 +1,347 @@ +/** + * @file NodeContextMenu.js + * Handles right-click context menu for adding nodes to the workspace. + * Simplified from Picograph's WorkspaceContextMenuManager. + */ + +import { NodeRegistry } from './nodes/NodeRegistry.js'; + +/** + * Manages context menu for adding nodes on right-click. + */ +export class NodeContextMenu { + /** @type {HTMLElement} */ + #canvas; + + /** @type {HTMLDivElement | null} */ + #container = null; + + /** @type {HTMLInputElement | null} */ + #searchInput = null; + + /** @type {HTMLDivElement | null} */ + #list = null; + + /** @type {boolean} */ + #isVisible = false; + + /** @type {{ x: number, y: number }} */ + #spawnPosition = { x: 0, y: 0 }; + + /** @type {string[]} */ + #filteredTypes = []; + + /** @type {number} */ + #selectedIndex = -1; + + /** @type {HTMLButtonElement[]} */ + #itemElements = []; + + /** @type {((type: string, worldX: number, worldY: number) => void) | null} */ + #onSpawnNode = null; + + /** + * @param {HTMLElement} canvas Workspace canvas element. + * @param {(type: string, worldX: number, worldY: number) => void} onSpawnNode Callback to spawn node. + */ + constructor(canvas, onSpawnNode) { + this.#canvas = canvas; + this.#onSpawnNode = onSpawnNode; + } + + /** + * Initializes the context menu DOM. + */ + initialize() { + if (this.#container) return; + + const container = document.createElement('div'); + container.className = 'node-context-menu'; + container.setAttribute('role', 'dialog'); + container.setAttribute('aria-label', 'Add node'); + + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'context-menu-search'; + search.placeholder = 'Search nodes'; + search.setAttribute('aria-label', 'Search nodes'); + container.appendChild(search); + + const list = document.createElement('div'); + list.className = 'context-menu-list'; + list.setAttribute('role', 'listbox'); + container.appendChild(list); + + document.body.appendChild(container); + + search.addEventListener('input', () => { + this.#renderResults(search.value); + }); + + search.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + this.hide(); + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.#moveSelection(1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.#moveSelection(-1); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + this.#selectCurrent(); + } + }); + + document.addEventListener('pointerdown', (event) => { + if (!this.#isVisible) return; + if (!(event.target instanceof Node)) { + this.hide(); + return; + } + if (!container.contains(event.target)) { + this.hide(); + } + }); + + document.addEventListener('keydown', (event) => { + if (!this.#isVisible) return; + if (event.key === 'Escape') { + event.preventDefault(); + this.hide(); + } + }); + + this.#container = container; + this.#searchInput = search; + this.#list = list; + } + + /** + * Shows context menu at viewport coords. + * + * @param {number} clientX Viewport X. + * @param {number} clientY Viewport Y. + * @param {number} worldX World-space X for spawning. + * @param {number} worldY World-space Y for spawning. + */ + show(clientX, clientY, worldX, worldY) { + this.initialize(); + + if (!this.#container || !this.#searchInput) return; + + this.#spawnPosition = { x: worldX, y: worldY }; + this.#selectedIndex = -1; + this.#searchInput.value = ''; + this.#renderResults(''); + + this.#container.classList.add('is-visible'); + this.#container.style.left = `${clientX}px`; + this.#container.style.top = `${clientY}px`; + this.#isVisible = true; + + // Adjust if menu goes off-screen + const menuRect = this.#container.getBoundingClientRect(); + let adjustedLeft = clientX; + let adjustedTop = clientY; + + if (menuRect.right > window.innerWidth) { + adjustedLeft -= menuRect.right - window.innerWidth; + } + if (menuRect.bottom > window.innerHeight) { + adjustedTop -= menuRect.bottom - window.innerHeight; + } + + adjustedLeft = Math.max(0, adjustedLeft); + adjustedTop = Math.max(0, adjustedTop); + + this.#container.style.left = `${adjustedLeft}px`; + this.#container.style.top = `${adjustedTop}px`; + + requestAnimationFrame(() => { + this.#searchInput?.focus(); + }); + } + + /** + * Hides the context menu. + */ + hide() { + if (!this.#container || !this.#isVisible) return; + + this.#container.classList.remove('is-visible'); + this.#isVisible = false; + this.#selectedIndex = -1; + this.#itemElements = []; + this.#filteredTypes = []; + if (this.#searchInput) { + this.#searchInput.value = ''; + } + } + + /** + * Returns true if context menu is visible. + * + * @returns {boolean} + */ + isVisible() { + return this.#isVisible; + } + + /** + * Renders filtered node types based on query. + * + * @param {string} query Search query. + */ + #renderResults(query) { + if (!this.#list) return; + + const allTypes = NodeRegistry.BlueprintPure_GetRegisteredTypes(); + const normalizedQuery = query.trim().toLowerCase(); + + this.#filteredTypes = normalizedQuery + ? allTypes.filter(type => type.toLowerCase().includes(normalizedQuery)) + : allTypes.slice(); + + this.#list.innerHTML = ''; + this.#itemElements = []; + + if (!this.#filteredTypes.length) { + const empty = document.createElement('p'); + empty.className = 'context-menu-empty'; + empty.textContent = 'No matching nodes'; + this.#list.appendChild(empty); + this.#selectedIndex = -1; + return; + } + + this.#filteredTypes.forEach((type, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'context-menu-item'; + button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', 'false'); + button.textContent = this.#formatNodeTypeName(type); + + button.addEventListener('click', () => { + this.#updateSelection(index); + this.#selectCurrent(); + }); + + button.addEventListener('pointerenter', () => { + this.#updateSelection(index); + }); + + button.addEventListener('keydown', (event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.#moveSelection(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.#moveSelection(-1); + } + }); + + this.#list.appendChild(button); + this.#itemElements.push(button); + }); + + this.#updateSelection(0); + } + + /** + * Formats node type for display (e.g., "print_node" → "Print Node"). + * + * @param {string} type Node type string. + * @returns {string} + */ + #formatNodeTypeName(type) { + return type + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * Moves selection by offset. + * + * @param {number} offset Delta. + */ + #moveSelection(offset) { + const total = this.#filteredTypes.length; + if (!total || !Number.isFinite(offset) || offset === 0) return; + + let index = this.#selectedIndex; + if (index < 0) { + index = offset > 0 ? 0 : total - 1; + } else { + index = (index + offset + total) % total; + } + + this.#updateSelection(index); + this.#focusSelection(); + } + + /** + * Updates visual selection. + * + * @param {number} index Target index. + */ + #updateSelection(index) { + const previousIndex = this.#selectedIndex; + const items = this.#itemElements; + + if (previousIndex >= 0 && items[previousIndex]) { + items[previousIndex].classList.remove('is-selected'); + items[previousIndex].setAttribute('aria-selected', 'false'); + } + + if (index < 0 || index >= this.#filteredTypes.length) { + this.#selectedIndex = -1; + return; + } + + this.#selectedIndex = index; + const element = items[index]; + if (element) { + element.classList.add('is-selected'); + element.setAttribute('aria-selected', 'true'); + element.scrollIntoView({ block: 'nearest' }); + } + } + + /** + * Focuses currently selected element. + */ + #focusSelection() { + const element = this.#itemElements[this.#selectedIndex]; + if (!element) return; + try { + element.focus({ preventScroll: true }); + } catch { + element.focus(); + } + } + + /** + * Spawns node for current selection. + */ + #selectCurrent() { + if (this.#selectedIndex < 0 || this.#selectedIndex >= this.#filteredTypes.length) { + return; + } + + const type = this.#filteredTypes[this.#selectedIndex]; + if (this.#onSpawnNode) { + this.#onSpawnNode(type, this.#spawnPosition.x, this.#spawnPosition.y); + } + this.hide(); + } +} diff --git a/website/scripts/NodeRenderer.js b/website/scripts/NodeRenderer.js index 496673c..a1c530a 100644 --- a/website/scripts/NodeRenderer.js +++ b/website/scripts/NodeRenderer.js @@ -114,6 +114,51 @@ export class NodeRenderer { 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. * diff --git a/website/scripts/WorkspaceNavigator.js b/website/scripts/WorkspaceNavigator.js index a18a27e..a7516c4 100644 --- a/website/scripts/WorkspaceNavigator.js +++ b/website/scripts/WorkspaceNavigator.js @@ -130,6 +130,17 @@ export class WorkspaceNavigator { /** @returns {number} */ get zoomLevel() { return this.#zoomLevel; } + /** + * Checks if a pan operation occurred recently (within the threshold). + * Useful for suppressing context menus after right-click drag pans. + * + * @param {number} [thresholdMs=100] - Time window in milliseconds + * @returns {boolean} + */ + didPanRecently(thresholdMs = 100) { + return (this.#timestamp() - this.#lastPanTimestamp) < thresholdMs; + } + /** * Returns the total effective offset (backgroundOffset + pendingLayerOffset). * Matches WorkspaceNavigationManager.getEffectiveOffset() in Picograph. diff --git a/website/scripts/main.js b/website/scripts/main.js index 869b6b3..1d2de45 100644 --- a/website/scripts/main.js +++ b/website/scripts/main.js @@ -12,6 +12,8 @@ import { GraphExecutor } from "./GraphExecutor.js"; import { PrintBubble } from "./PrintBubble.js"; import { SpatialAudio } from "./SpatialAudio.js"; import { getTypeColor } from "./getTypeColor.js"; +import { NodeContextMenu } from "./NodeContextMenu.js"; +import { NodeRegistry } from "./nodes/NodeRegistry.js"; const canvas = document.getElementById("workspaceCanvas"); const worldLayer = document.getElementById("worldLayer"); @@ -85,6 +87,43 @@ const dragger = new ItemDragger(canvas, nav, nodeRend, nav.setDragInProgressChecker(() => dragger.isDragging); // Drop the active drag immediately when a pinch-to-zoom starts so the two gestures don't conflict nav.setOnPinchStart(() => dragger.cancelDrag()); + +// Node context menu for adding nodes +const nodeContextMenu = new NodeContextMenu(canvas, (type, worldX, worldY) => { + // Generate unique node ID + const existingIds = nodeRend.getNodes().map(n => n.id); + let counter = 1; + let newId = type; + while (existingIds.includes(newId)) { + counter++; + newId = `${type}_${counter}`; + } + + // Get default pins from node definition + const pins = NodeRegistry.BlueprintPure_GetDefaultPins(type); + + // Create and add new node + const newNode = new GraphNode({ + id: newId, + type: type, + title: type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), + position: { x: Math.round(worldX), y: Math.round(worldY) }, + inputs: pins.inputs.map(p => ({ ...p })), + outputs: pins.outputs.map(p => ({ ...p })), + userSpawned: true + }); + + nodeRend.addNode(newNode); + dragger.registerNode(newId); + connRend.render(); + + // Attach PrintBubble if it's a print node + if (type === "print") { + const el = nodeRend.getNodeElements().get(newId); + if (el) printBubbles.set(newId, new PrintBubble(el)); + } +}); + const debugPanel = new DebugPanel(debugPanelEl, (enabled) => { dragger.enabled = enabled; pinConns.enabled = enabled; }, () => { @@ -497,6 +536,7 @@ let graphData = await response.json(); // Load spatial audio await audio.load('pop', 'data/pop.mp3'); +await audio.load('denied', 'data/denied.mp3'); graphData.comments?.forEach(c => nodeRend.addComment(new GraphComment(c))); graphData.nodes.forEach(n => nodeRend.addNode(new GraphNode(n))); @@ -667,6 +707,68 @@ canvas.addEventListener("click", (e) => { } }); +// Delete key to remove selected node +document.addEventListener("keydown", (e) => { + // Skip if user is typing in an input + if (e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target.isContentEditable) { + return; + } + + if (e.key === "Delete" || e.key === "Backspace") { + const activeId = dragger.getActiveId(); + if (activeId) { + const node = nodeRend.getNodes().find(n => n.id === activeId); + if (node?.userSpawned) { + e.preventDefault(); + dragger.unregisterNode(activeId); + nodeRend.removeNode(activeId); + printBubbles.delete(activeId); + propertiesPanel.loadNode(null); + connRend.render(); + } else if (node) { + // Node can't be deleted - shake it red and play denied sound + e.preventDefault(); + const nodeEl = nodeRend.getNodeElements().get(activeId); + if (nodeEl) { + nodeEl.classList.add('node-shake-red'); + nodeEl.addEventListener('animationend', () => { + nodeEl.classList.remove('node-shake-red'); + }, { once: true }); + } + + // Play denied sound at node position + const rect = nodeRend.getNodeWorldRect(activeId); + if (rect) { + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + audio.play('denied', centerX, centerY, rect.width, rect.height); + } + } + } + } +}); + +// Right-click context menu for adding nodes +canvas.addEventListener("contextmenu", (e) => { + e.preventDefault(); + + // Don't show menu if we just finished a pan drag + if (nav.didPanRecently(150)) { + return; + } + + // Convert client coords to world coords + const rect = canvas.getBoundingClientRect(); + const zoom = Math.max(0.01, nav.zoomLevel || 1); + const offset = nav.getEffectiveOffset(); + const worldX = (e.clientX - rect.left) / zoom - offset.x; + const worldY = (e.clientY - rect.top) / zoom - offset.y; + + nodeContextMenu.show(e.clientX, e.clientY, worldX, worldY); +}); + // Initialize dragging as disabled dragger.enabled = false; pinConns.enabled = false; diff --git a/website/scripts/nodes/BindEventNode.js b/website/scripts/nodes/BindEventNode.js index 95166c8..5e0bee9 100644 --- a/website/scripts/nodes/BindEventNode.js +++ b/website/scripts/nodes/BindEventNode.js @@ -15,6 +15,21 @@ export class BindEventNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "bind_event"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'exec_in', name: '', direction: 'input', kind: 'exec' }, + { id: 'event_name', name: 'Event', direction: 'input', kind: 'string', defaultValue: 'click' } + ], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintCallable) * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx diff --git a/website/scripts/nodes/ButtonNode.js b/website/scripts/nodes/ButtonNode.js index 34f9538..9df9ac8 100644 --- a/website/scripts/nodes/ButtonNode.js +++ b/website/scripts/nodes/ButtonNode.js @@ -14,6 +14,18 @@ export class ButtonNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "button"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/ChatConnectNode.js b/website/scripts/nodes/ChatConnectNode.js index 9a3c68c..43b9c62 100644 --- a/website/scripts/nodes/ChatConnectNode.js +++ b/website/scripts/nodes/ChatConnectNode.js @@ -14,6 +14,22 @@ export class ChatConnectNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "chat_connect"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'exec_in', name: '', direction: 'input', kind: 'exec' } + ], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' }, + { id: 'on_message', name: 'On Message', direction: 'output', kind: 'exec' }, + { id: 'message', name: 'Message', direction: 'output', kind: 'string' } + ] + }; + } + /** * @UFUNCTION(BlueprintCallable) * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx diff --git a/website/scripts/nodes/CustomEventNode.js b/website/scripts/nodes/CustomEventNode.js index eb751b8..556392e 100644 --- a/website/scripts/nodes/CustomEventNode.js +++ b/website/scripts/nodes/CustomEventNode.js @@ -16,6 +16,18 @@ export class CustomEventNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "custom_event"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/GetMatrixChatNode.js b/website/scripts/nodes/GetMatrixChatNode.js index aec2773..d4116a4 100644 --- a/website/scripts/nodes/GetMatrixChatNode.js +++ b/website/scripts/nodes/GetMatrixChatNode.js @@ -16,6 +16,18 @@ export class GetMatrixChatNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "get_matrix_chat"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [], + outputs: [ + { id: 'chat', name: 'Chat', direction: 'output', kind: 'table' } + ] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/InfoNode.js b/website/scripts/nodes/InfoNode.js index 9b5b936..da8812f 100644 --- a/website/scripts/nodes/InfoNode.js +++ b/website/scripts/nodes/InfoNode.js @@ -14,6 +14,16 @@ export class InfoNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "info"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [], + outputs: [] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/LottieNode.js b/website/scripts/nodes/LottieNode.js index 1953788..306373a 100644 --- a/website/scripts/nodes/LottieNode.js +++ b/website/scripts/nodes/LottieNode.js @@ -16,6 +16,16 @@ export class LottieNode extends NodeBase { /** @type {string} */ static NodeType = "lottie"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [], + outputs: [] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/MatrixChatNode.js b/website/scripts/nodes/MatrixChatNode.js index fe83523..3dde843 100644 --- a/website/scripts/nodes/MatrixChatNode.js +++ b/website/scripts/nodes/MatrixChatNode.js @@ -17,6 +17,22 @@ export class MatrixChatNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "chat"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'exec_in', name: '', direction: 'input', kind: 'exec' }, + { id: 'homeserver', name: 'Homeserver', direction: 'input', kind: 'string', defaultValue: 'https://matrix.org' }, + { id: 'room_id', name: 'Room ID', direction: 'input', kind: 'string', defaultValue: '' } + ], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/NodeBase.js b/website/scripts/nodes/NodeBase.js index 919d410..be89ee6 100644 --- a/website/scripts/nodes/NodeBase.js +++ b/website/scripts/nodes/NodeBase.js @@ -27,6 +27,17 @@ export class NodeBase { */ static NodeType = ""; + /** + * Returns default pin configuration for this node type. + * Override in subclasses to define node-specific pins. + * + * @UFUNCTION(BlueprintPure) + * @returns {{ inputs: Array<{id: string, name: string, direction: string, kind: string, defaultValue?: string, min?: number, max?: number}>, outputs: Array<{id: string, name: string, direction: string, kind: string}> }} + */ + static BlueprintPure_GetDefaultPins() { + return { inputs: [], outputs: [] }; + } + /** * Called after the node's base DOM (header, pins) has been built. * Override to inject type-specific UI into the article element. diff --git a/website/scripts/nodes/NodeRegistry.js b/website/scripts/nodes/NodeRegistry.js index 4ad336f..2b6377c 100644 --- a/website/scripts/nodes/NodeRegistry.js +++ b/website/scripts/nodes/NodeRegistry.js @@ -47,4 +47,19 @@ export class NodeRegistry { static BlueprintPure_GetRegisteredTypes() { return [...this.#registry.keys()]; } + + /** + * Returns default pin configuration for a node type by delegating to the node class. + * + * @UFUNCTION(BlueprintPure) + * @param {string} nodeType + * @returns {{ inputs: Array<{id: string, name: string, direction: string, kind: string, defaultValue?: string}>, outputs: Array<{id: string, name: string, direction: string, kind: string}> }} + */ + static BlueprintPure_GetDefaultPins(nodeType) { + const handler = this.BlueprintPure_Get(nodeType); + if (!handler) { + return { inputs: [], outputs: [] }; + } + return handler.constructor.BlueprintPure_GetDefaultPins(); + } } diff --git a/website/scripts/nodes/PrintNode.js b/website/scripts/nodes/PrintNode.js index b69b1ed..c45a31d 100644 --- a/website/scripts/nodes/PrintNode.js +++ b/website/scripts/nodes/PrintNode.js @@ -14,6 +14,21 @@ export class PrintNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "print"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'exec_in', name: '', direction: 'input', kind: 'exec' }, + { id: 'value', name: 'Value', direction: 'input', kind: 'string', defaultValue: 'Hello!' } + ], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintCallable) * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx diff --git a/website/scripts/nodes/PureNode.js b/website/scripts/nodes/PureNode.js index 8581991..1299b76 100644 --- a/website/scripts/nodes/PureNode.js +++ b/website/scripts/nodes/PureNode.js @@ -14,6 +14,18 @@ export class PureNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "pure"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [], + outputs: [ + { id: 'val_out', name: 'Value', direction: 'output', kind: 'string', defaultValue: '' } + ] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/RandomNameNode.js b/website/scripts/nodes/RandomNameNode.js index 2a57eac..1f3b57e 100644 --- a/website/scripts/nodes/RandomNameNode.js +++ b/website/scripts/nodes/RandomNameNode.js @@ -24,6 +24,21 @@ export class RandomNameNode extends NodeBase { /** @type {Promise | null} */ static #loadPromise = null; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'exec_in', name: '', direction: 'input', kind: 'exec' } + ], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' }, + { id: 'name', name: 'Name', direction: 'output', kind: 'string' } + ] + }; + } + /** * Load name parts from JSON */ @@ -59,6 +74,28 @@ export class RandomNameNode extends NodeBase { return `${first}${second}`; } + /** + * @UFUNCTION(BlueprintCallable) + * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx + */ + async BlueprintCallable_Execute(ctx) { + const graphNode = ctx.BlueprintPure_GetNode(); + if (!graphNode) return; + + // Generate new name + graphNode._generatedName = RandomNameNode.generateName(); + + // Update DOM if rendered + const nodeEl = document.querySelector(`[data-node-id="${graphNode.id}"]`); + if (nodeEl) { + const nameDisplay = nodeEl.querySelector('.node-name-display'); + if (nameDisplay) { + nameDisplay.textContent = graphNode._generatedName; + } + } + + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/scripts/nodes/SequenceNode.js b/website/scripts/nodes/SequenceNode.js index e35dfa9..4eb50d3 100644 --- a/website/scripts/nodes/SequenceNode.js +++ b/website/scripts/nodes/SequenceNode.js @@ -14,6 +14,21 @@ export class SequenceNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "sequence"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'exec_in', name: '', direction: 'input', kind: 'exec' } + ], + outputs: [ + { id: 'then_1', name: 'Then 1', direction: 'output', kind: 'exec' }, + { id: 'then_2', name: 'Then 2', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintCallable) * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx diff --git a/website/scripts/nodes/TimerNode.js b/website/scripts/nodes/TimerNode.js index 644c966..52ef3c3 100644 --- a/website/scripts/nodes/TimerNode.js +++ b/website/scripts/nodes/TimerNode.js @@ -14,6 +14,20 @@ export class TimerNode extends NodeBase { /** @UCLASS(BlueprintType) */ static NodeType = "timer"; + /** + * @UFUNCTION(BlueprintPure) + */ + static BlueprintPure_GetDefaultPins() { + return { + inputs: [ + { id: 'interval', name: 'Interval (s)', direction: 'input', kind: 'number', defaultValue: '1', min: 0.1, max: 10 } + ], + outputs: [ + { id: 'exec_out', name: '', direction: 'output', kind: 'exec' } + ] + }; + } + /** * @UFUNCTION(BlueprintNativeEvent) * @param {HTMLElement} article diff --git a/website/styles/main.css b/website/styles/main.css index bbff420..b928d60 100644 --- a/website/styles/main.css +++ b/website/styles/main.css @@ -2074,3 +2074,120 @@ svg.world-image { text-align: right; } +/* ─── Node Context Menu ────────────────────────────────────────────────────── */ + +.node-context-menu { + position: fixed; + display: none; + flex-direction: column; + gap: 0.4rem; + min-width: 220px; + max-width: 320px; + max-height: 400px; + padding: 0.5rem; + background: rgba(20, 20, 28, 0.98); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + z-index: 1000; +} + +.node-context-menu.is-visible { + display: flex; +} + +.context-menu-search { + width: 100%; + padding: 0.5rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.95); + font-size: 0.85rem; + outline: none; + transition: all 0.15s ease; +} + +.context-menu-search:focus { + background: rgba(255, 255, 255, 0.09); + border-color: rgba(100, 181, 246, 0.5); +} + +.context-menu-search::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.context-menu-list { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.context-menu-list::-webkit-scrollbar { + width: 6px; +} + +.context-menu-list::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.context-menu-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.context-menu-item { + width: 100%; + display: flex; + align-items: center; + padding: 0.5rem 0.7rem; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: rgba(255, 255, 255, 0.9); + font-size: 0.85rem; + text-align: left; + cursor: pointer; + transition: all 0.12s ease; +} + +.context-menu-item:hover, +.context-menu-item:focus-visible { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); + outline: none; +} + +.context-menu-item:active { + background: rgba(255, 255, 255, 0.12); +} + +.context-menu-item.is-selected, +.context-menu-item[aria-selected='true'] { + background: rgba(100, 181, 246, 0.15); + border-color: rgba(100, 181, 246, 0.4); + color: rgba(255, 255, 255, 1); + box-shadow: inset 3px 0 0 #64b5f6; +} + +.context-menu-empty { + margin: 0; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + text-align: center; + padding: 1rem 0; +} + +@keyframes node-shake-red { + 0%, 100% { margin-left: 0; filter: drop-shadow(0 0 0 rgba(255, 82, 82, 0)); } + 10%, 30%, 50%, 70%, 90% { margin-left: -4px; filter: drop-shadow(0 0 8px rgba(255, 82, 82, 0.6)); } + 20%, 40%, 60%, 80% { margin-left: 4px; filter: drop-shadow(0 0 8px rgba(255, 82, 82, 0.6)); } +} + +.node-shake-red { + animation: node-shake-red 0.4s ease-in-out; +}