mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
can now add nodes
This commit is contained in:
BIN
website/data/denied.mp3
Normal file
BIN
website/data/denied.mp3
Normal file
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
347
website/scripts/NodeContextMenu.js
Normal file
347
website/scripts/NodeContextMenu.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user