can now add nodes

This commit is contained in:
2026-04-19 05:45:33 +10:00
parent 7e7e6f2c22
commit 34c2aa4316
23 changed files with 864 additions and 1 deletions

BIN
website/data/denied.mp3 Normal file

Binary file not shown.

View File

@@ -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;
}

View File

@@ -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
*/

View File

@@ -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();
}
}

View File

@@ -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.
*

View File

@@ -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.

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -24,6 +24,21 @@ export class RandomNameNode extends NodeBase {
/** @type {Promise<void> | 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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}