feat: add add menu

This commit is contained in:
max_richter 2024-03-21 01:23:08 +01:00
parent 84bcfa61d8
commit e80ecd2302
11 changed files with 278 additions and 70 deletions

View File

@ -0,0 +1,164 @@
<script lang="ts">
import type { GraphManager } from "$lib/graph-manager";
import { HTML } from "@threlte/extras";
import { onMount } from "svelte";
export let position: [x: number, y: number] | null;
export let graph: GraphManager;
let input: HTMLInputElement;
let value: string = "";
let activeNodeId: string = "";
const allNodes = graph.getNodeTypes();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value));
}
$: nodes = value === "" ? allNodes : filterNodes();
$: if (nodes) {
if (activeNodeId === "") {
activeNodeId = nodes[0].id;
} else if (nodes.length) {
const node = nodes.find((node) => node.id === activeNodeId);
if (!node) {
activeNodeId = nodes[0].id;
}
}
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
const value = (event.target as HTMLInputElement).value;
if (event.key === "Escape") {
position = null;
return;
}
if (event.key === "ArrowDown") {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index + 1) % nodes.length].id;
return;
}
if (event.key === "ArrowUp") {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index - 1 + nodes.length) % nodes.length].id;
return;
}
if (event.key === "Enter") {
if (activeNodeId && position) {
graph.createNode({ type: activeNodeId, position });
position = null;
}
return;
}
}
onMount(() => {
input.disabled = false;
setTimeout(() => input.focus(), 50);
});
</script>
<HTML position.x={position?.[0]} position.z={position?.[1]} transform={false}>
<div class="wrapper">
<div class="header">
<input
id="add-menu"
type="text"
aria-label="Search for a node type"
role="searchbox"
placeholder="Search..."
disabled={false}
on:keydown={handleKeyDown}
bind:value
bind:this={input}
/>
</div>
<div class="content">
{#each nodes as node}
<div
class="result"
role="treeitem"
tabindex="0"
aria-selected={node.id === activeNodeId}
on:keydown={(event) => {
if (event.key === "Enter") {
if (position) {
graph.createNode({ type: node.id, position });
position = null;
}
}
}}
on:mousedown={() => {
if (position) {
graph.createNode({ type: node.id, position });
position = null;
}
}}
on:focus={() => {
activeNodeId = node.id;
}}
class:selected={node.id === activeNodeId}
on:mouseover={() => {
activeNodeId = node.id;
}}
>
{node.id}
</div>
{/each}
</div>
</div>
</HTML>
<style>
input {
background: var(--background-color-lighter);
font-family: var(--font-family);
border: none;
color: var(--text-color);
padding: 0.8em;
width: calc(100% - 2px);
box-sizing: border-box;
font-size: 1em;
margin-left: 1px;
margin-top: 1px;
}
input:focus {
outline: solid 2px rgba(255, 255, 255, 0.2);
}
.wrapper {
position: absolute;
background: var(--background-color);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--background-color-lighter);
width: 150px;
}
.content {
min-height: none;
width: 100%;
color: var(--text-color);
}
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--background-color-lighter);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected="true"] {
background: var(--background-color-lighter);
opacity: 1;
}
</style>

View File

@ -18,6 +18,7 @@
selectedNodes, selectedNodes,
} from "./stores"; } from "./stores";
import BoxSelection from "../BoxSelection.svelte"; import BoxSelection from "../BoxSelection.svelte";
import AddMenu from "../AddMenu.svelte";
export let graph: GraphManager; export let graph: GraphManager;
setContext("graphManager", graph); setContext("graphManager", graph);
@ -36,6 +37,7 @@
let loaded = false; let loaded = false;
const cameraDown = [0, 0]; const cameraDown = [0, 0];
let cameraPosition: [number, number, number] = [0, 0, 4]; let cameraPosition: [number, number, number] = [0, 0, 4];
let addMenuPosition: [number, number] | null = null;
$: if (cameraPosition && loaded) { $: if (cameraPosition && loaded) {
localStorage.setItem("cameraPosition", JSON.stringify(cameraPosition)); localStorage.setItem("cameraPosition", JSON.stringify(cameraPosition));
@ -79,16 +81,19 @@
node.tmp.ref.style.setProperty("--ny", `${node.tmp.y * 10}px`); node.tmp.ref.style.setProperty("--ny", `${node.tmp.y * 10}px`);
node.tmp.mesh.position.x = node.tmp.x + 10; node.tmp.mesh.position.x = node.tmp.x + 10;
node.tmp.mesh.position.z = node.tmp.y + getNodeHeight(node.type) / 2; node.tmp.mesh.position.z = node.tmp.y + getNodeHeight(node.type) / 2;
if (node.tmp.x === node.position.x && node.tmp.y === node.position.y) { if (
node.tmp.x === node.position[0] &&
node.tmp.y === node.position[1]
) {
delete node.tmp.x; delete node.tmp.x;
delete node.tmp.y; delete node.tmp.y;
} }
} else { } else {
node.tmp.ref.style.setProperty("--nx", `${node.position.x * 10}px`); node.tmp.ref.style.setProperty("--nx", `${node.position[0] * 10}px`);
node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`); node.tmp.ref.style.setProperty("--ny", `${node.position[1] * 10}px`);
node.tmp.mesh.position.x = node.position.x + 10; node.tmp.mesh.position.x = node.position[0] + 10;
node.tmp.mesh.position.z = node.tmp.mesh.position.z =
node.position.y + getNodeHeight(node.type) / 2; node.position[1] + getNodeHeight(node.type) / 2;
} }
} }
} }
@ -113,10 +118,10 @@
const height = getNodeHeight(node.type); const height = getNodeHeight(node.type);
const width = 20; const width = 20;
return ( return (
node.position.x > cameraBounds[0] - width && node.position[0] > cameraBounds[0] - width &&
node.position.x < cameraBounds[1] && node.position[0] < cameraBounds[1] &&
node.position.y > cameraBounds[2] - height && node.position[1] > cameraBounds[2] - height &&
node.position.y < cameraBounds[3] node.position[1] < cameraBounds[3]
); );
}); });
@ -141,8 +146,8 @@
event.clientY, event.clientY,
); );
for (const node of $nodes.values()) { for (const node of $nodes.values()) {
const x = node.position.x; const x = node.position[0];
const y = node.position.y; const y = node.position[1];
const height = getNodeHeight(node.type); const height = getNodeHeight(node.type);
if (downX > x && downX < x + 20 && downY > y && downY < y + height) { if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
clickedNodeId = node.id; clickedNodeId = node.id;
@ -213,14 +218,14 @@
): [number, number] { ): [number, number] {
if (typeof index === "number") { if (typeof index === "number") {
return [ return [
(node?.tmp?.x ?? node.position.x) + 20, (node?.tmp?.x ?? node.position[0]) + 20,
(node?.tmp?.y ?? node.position.y) + 2.5 + 10 * index, (node?.tmp?.y ?? node.position[1]) + 2.5 + 10 * index,
]; ];
} else { } else {
const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index); const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index);
return [ return [
node?.tmp?.x ?? node.position.x, node?.tmp?.x ?? node.position[0],
(node?.tmp?.y ?? node.position.y) + 10 + 10 * _index, (node?.tmp?.y ?? node.position[1]) + 10 + 10 * _index,
]; ];
} }
} }
@ -273,8 +278,8 @@
const y2 = Math.max(mouseD[1], mousePosition[1]); const y2 = Math.max(mouseD[1], mousePosition[1]);
for (const node of $nodes.values()) { for (const node of $nodes.values()) {
if (!node?.tmp) continue; if (!node?.tmp) continue;
const x = node.position.x; const x = node.position[0];
const y = node.position.y; const y = node.position[1];
const height = getNodeHeight(node.type); const height = getNodeHeight(node.type);
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) { if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
$selectedNodes?.add(node.id); $selectedNodes?.add(node.id);
@ -420,15 +425,15 @@
const node = graph.getNode($activeNodeId); const node = graph.getNode($activeNodeId);
if (!node) return; if (!node) return;
node.tmp = node.tmp || {}; node.tmp = node.tmp || {};
node.tmp.downX = node.position.x; node.tmp.downX = node.position[0];
node.tmp.downY = node.position.y; node.tmp.downY = node.position[1];
if ($selectedNodes) { if ($selectedNodes) {
for (const nodeId of $selectedNodes) { for (const nodeId of $selectedNodes) {
const n = graph.getNode(nodeId); const n = graph.getNode(nodeId);
if (!n) continue; if (!n) continue;
n.tmp = n.tmp || {}; n.tmp = n.tmp || {};
n.tmp.downX = n.position.x; n.tmp.downX = n.position[0];
n.tmp.downY = n.position.y; n.tmp.downY = n.position[1];
} }
} }
} }
@ -438,6 +443,11 @@
document.activeElement === document.body || document.activeElement === document.body ||
document?.activeElement?.id === "graph"; document?.activeElement?.id === "graph";
if (event.key === "l") {
const activeNode = graph.getNode($activeNodeId);
console.log(activeNode);
}
if (event.key === "Escape") { if (event.key === "Escape") {
$activeNodeId = -1; $activeNodeId = -1;
$selectedNodes?.clear(); $selectedNodes?.clear();
@ -445,14 +455,18 @@
(document.activeElement as HTMLElement)?.blur(); (document.activeElement as HTMLElement)?.blur();
} }
if (event.key === "A" && event.shiftKey) {
addMenuPosition = [mousePosition[0], mousePosition[1]];
}
if (event.key === ".") { if (event.key === ".") {
const average = [0, 0]; const average = [0, 0];
for (const node of $nodes.values()) { for (const node of $nodes.values()) {
average[0] += node.position.x; average[0] += node.position[0];
average[1] += node.position.y; average[1] += node.position[1];
} }
average[0] /= $nodes.size; average[0] = average[0] ? average[0] / $nodes.size : 0;
average[1] /= $nodes.size; average[1] = average[1] ? average[1] / $nodes.size : 0;
const camX = cameraPosition[0]; const camX = cameraPosition[0];
const camY = cameraPosition[1]; const camY = cameraPosition[1];
@ -466,6 +480,7 @@
lerp(camY, average[1], ease(a)), lerp(camY, average[1], ease(a)),
lerp(camZ, 2, ease(a)), lerp(camZ, 2, ease(a)),
); );
if (mouseDown) return false;
}); });
} }
@ -538,12 +553,12 @@
activeNode.tmp = activeNode.tmp || {}; activeNode.tmp = activeNode.tmp || {};
activeNode.tmp.isMoving = false; activeNode.tmp.isMoving = false;
const snapLevel = getSnapLevel(); const snapLevel = getSnapLevel();
activeNode.position.x = snapToGrid( activeNode.position[0] = snapToGrid(
activeNode?.tmp?.x ?? activeNode.position.x, activeNode?.tmp?.x ?? activeNode.position[0],
5 / snapLevel, 5 / snapLevel,
); );
activeNode.position.y = snapToGrid( activeNode.position[1] = snapToGrid(
activeNode?.tmp?.y ?? activeNode.position.y, activeNode?.tmp?.y ?? activeNode.position[1],
5 / snapLevel, 5 / snapLevel,
); );
const nodes = [ const nodes = [
@ -551,8 +566,8 @@
] as NodeType[]; ] as NodeType[];
const vec = [ const vec = [
activeNode.position.x - (activeNode?.tmp.x || 0), activeNode.position[0] - (activeNode?.tmp.x || 0),
activeNode.position.y - (activeNode?.tmp.y || 0), activeNode.position[1] - (activeNode?.tmp.y || 0),
]; ];
for (const node of nodes) { for (const node of nodes) {
@ -560,8 +575,8 @@
node.tmp = node.tmp || {}; node.tmp = node.tmp || {};
const { x, y } = node.tmp; const { x, y } = node.tmp;
if (x !== undefined && y !== undefined) { if (x !== undefined && y !== undefined) {
node.position.x = x + vec[0]; node.position[0] = x + vec[0];
node.position.y = y + vec[1]; node.position[1] = y + vec[1];
} }
} }
nodes.push(activeNode); nodes.push(activeNode);
@ -572,8 +587,8 @@
node.tmp["x"] !== undefined && node.tmp["x"] !== undefined &&
node.tmp["y"] !== undefined node.tmp["y"] !== undefined
) { ) {
node.tmp.x = lerp(node.tmp.x, node.position.x, a); node.tmp.x = lerp(node.tmp.x, node.position[0], a);
node.tmp.y = lerp(node.tmp.y, node.position.y, a); node.tmp.y = lerp(node.tmp.y, node.position[1], a);
updateNodePosition(node); updateNodePosition(node);
if (node?.tmp?.isMoving) { if (node?.tmp?.isMoving) {
return false; return false;
@ -627,6 +642,7 @@
$possibleSockets = []; $possibleSockets = [];
$possibleSocketIds = null; $possibleSocketIds = null;
$hoveredSocket = null; $hoveredSocket = null;
addMenuPosition = null;
} }
onMount(() => { onMount(() => {
@ -669,12 +685,17 @@
{/if} {/if}
{#if $status === "idle"} {#if $status === "idle"}
{#if addMenuPosition}
<AddMenu bind:position={addMenuPosition} {graph} />
{/if}
{#if $activeSocket} {#if $activeSocket}
<FloatingEdge <FloatingEdge
from={{ x: $activeSocket.position[0], y: $activeSocket.position[1] }} from={{ x: $activeSocket.position[0], y: $activeSocket.position[1] }}
to={{ x: mousePosition[0], y: mousePosition[1] }} to={{ x: mousePosition[0], y: mousePosition[1] }}
/> />
{/if} {/if}
{#key $graphId} {#key $graphId}
<GraphView {nodes} {edges} {cameraPosition} /> <GraphView {nodes} {edges} {cameraPosition} />
{/key} {/key}

View File

@ -29,14 +29,14 @@
onMount(() => { onMount(() => {
for (const node of $nodes.values()) { for (const node of $nodes.values()) {
if (node?.tmp?.ref) { if (node?.tmp?.ref) {
node.tmp.ref.style.setProperty("--nx", `${node.position.x * 10}px`); node.tmp.ref.style.setProperty("--nx", `${node.position[0] * 10}px`);
node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`); node.tmp.ref.style.setProperty("--ny", `${node.position[1] * 10}px`);
} }
} }
}); });
</script> </script>
{#each $edges as edge (edge[0].id + edge[2].id + edge[3])} {#each $edges as edge (`${edge[0].id}-${edge[1]}-${edge[2].id}-${edge[3]}`)}
{@const pos = getEdgePosition(edge)} {@const pos = getEdgePosition(edge)}
{@const [x1, y1, x2, y2] = pos} {@const [x1, y1, x2, y2] = pos}
<Edge <Edge

View File

@ -11,7 +11,6 @@ export const hoveredSocket: Writable<Socket | null> = writable(null);
export const possibleSockets: Writable<Socket[]> = writable([]); export const possibleSockets: Writable<Socket[]> = writable([]);
export const possibleSocketIds: Writable<Set<string> | null> = writable(null); export const possibleSocketIds: Writable<Set<string> | null> = writable(null);
export const colors = writable({ export const colors = writable({
backgroundColorDarker: new Color().setStyle("#101010"), backgroundColorDarker: new Color().setStyle("#101010"),
backgroundColor: new Color().setStyle("#151515"), backgroundColor: new Color().setStyle("#151515"),
@ -38,13 +37,7 @@ if (browser) {
} }
globalThis["updateColors"] = updateColors;
body.addEventListener("transitionstart", () => { body.addEventListener("transitionstart", () => {
updateColors(); updateColors();
}) })
window.onload = () => {
updateColors();
}
} }

View File

@ -53,8 +53,8 @@
</script> </script>
<T.Mesh <T.Mesh
position.x={node.position.x + 10} position.x={node.position[0] + 10}
position.z={node.position.y + height / 2} position.z={node.position[1] + height / 2}
position.y={0.8} position.y={0.8}
rotation.x={-Math.PI / 2} rotation.x={-Math.PI / 2}
bind:ref={meshRef} bind:ref={meshRef}

View File

@ -1,5 +1,5 @@
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import { type Graph, type Node, type Edge, type Socket, type NodeRegistry, type RuntimeExecutor } from "./types"; import { type Graph, type Node, type Edge, type Socket, type NodeRegistry, type RuntimeExecutor, } from "./types";
import { HistoryManager } from "./history-manager"; import { HistoryManager } from "./history-manager";
import * as templates from "./graphs"; import * as templates from "./graphs";
import EventEmitter from "./helpers/EventEmitter"; import EventEmitter from "./helpers/EventEmitter";
@ -35,13 +35,13 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
} }
this.inputSockets.set(s); this.inputSockets.set(s);
}); });
this.execute = throttle(() => this._execute(), 100); this.execute = throttle(() => this._execute(), 50);
} }
serialize(): Graph { serialize(): Graph {
const nodes = Array.from(this._nodes.values()).map(node => ({ const nodes = Array.from(this._nodes.values()).map(node => ({
id: node.id, id: node.id,
position: { x: node.position.x, y: node.position.y }, position: node.position,
type: node.type, type: node.type,
props: node.props, props: node.props,
})); }));
@ -58,13 +58,18 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
console.log(`Execution took ${end - start}ms -> ${result}`); console.log(`Execution took ${end - start}ms -> ${result}`);
} }
getNodeTypes() {
return this.nodeRegistry.getAllNodes();
}
private _init(graph: Graph) { private _init(graph: Graph) {
const nodes = new Map(graph.nodes.map(node => { const nodes = new Map(graph.nodes.map(node => {
const nodeType = this.nodeRegistry.getNode(node.type); const nodeType = this.nodeRegistry.getNode(node.type);
if (nodeType) { if (nodeType) {
node.tmp = node.tmp || {}; node.tmp = {
node.tmp.type = nodeType; type: nodeType
};
} }
return [node.id, node] return [node.id, node]
})); }));
@ -177,6 +182,29 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
nodes.delete(node.id); nodes.delete(node.id);
return nodes; return nodes;
}); });
this.execute()
this.save();
}
private createNodeId() {
return Math.max(...this.getAllNodes().map(n => n.id), 0) + 1;
}
createNode({ type, position }: { type: string, position: [number, number] }) {
const nodeType = this.nodeRegistry.getNode(type);
if (!nodeType) {
console.error(`Node type not found: ${type}`);
return;
}
const node: Node = { id: this.createNodeId(), type, position, tmp: { type: nodeType } };
this.nodes.update((nodes) => {
nodes.set(node.id, node);
return nodes;
});
this.save(); this.save();
} }
@ -205,6 +233,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
return [...edges.filter(e => e[2].id !== to.id || e[3] !== toSocket), [from, fromSocket, to, toSocket]]; return [...edges.filter(e => e[2].id !== to.id || e[3] !== toSocket), [from, fromSocket, to, toSocket]];
}); });
this.execute();
this.save(); this.save();
} }
@ -216,7 +245,13 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
getParentsOfNode(node: Node) { getParentsOfNode(node: Node) {
const parents = []; const parents = [];
const stack = node.tmp?.parents?.slice(0); const stack = node.tmp?.parents?.slice(0);
while (stack?.length) { while (stack?.length) {
if (parents.length > 1000000) {
console.log("Infinite loop detected")
break;
}
const parent = stack.pop(); const parent = stack.pop();
if (!parent) continue; if (!parent) continue;
parents.push(parent); parents.push(parent);
@ -287,6 +322,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
this.edges.update((edges) => { this.edges.update((edges) => {
return edges.filter((e) => e[0].id !== id0 || e[1] !== sid0 || e[2].id !== id2 || e[3] !== sid2); return edges.filter((e) => e[0].id !== id0 || e[1] !== sid0 || e[2].id !== id2 || e[3] !== sid2);
}); });
this.execute();
this.save(); this.save();
} }

View File

@ -19,10 +19,7 @@ export function grid(width: number, height: number) {
tmp: { tmp: {
visible: false, visible: false,
}, },
position: { position: [x * 30, y * 40],
x: x * 30,
y: y * 40,
},
props: i == 0 ? { value: 0 } : {}, props: i == 0 ? { value: 0 } : {},
type: i == 0 ? "input/float" : "math", type: i == 0 ? "input/float" : "math",
}); });
@ -35,10 +32,7 @@ export function grid(width: number, height: number) {
tmp: { tmp: {
visible: false, visible: false,
}, },
position: { position: [width * 30, (height - 1) * 40],
x: width * 30,
y: (height - 1) * 40,
},
type: "output", type: "output",
props: {}, props: {},
}); });

View File

@ -6,12 +6,12 @@ export function tree(depth: number): Graph {
{ {
id: 0, id: 0,
type: "output", type: "output",
position: { x: 0, y: 0 } position: [0, 0]
}, },
{ {
id: 1, id: 1,
type: "math", type: "math",
position: { x: -40, y: -10 } position: [-40, -10]
} }
] ]
@ -34,13 +34,13 @@ export function tree(depth: number): Graph {
nodes.push({ nodes.push({
id: id0, id: id0,
type: "math", type: "math",
position: { x, y: y }, position: [x, y],
}); });
edges.push([id0, 0, parent, "a"]); edges.push([id0, 0, parent, "a"]);
nodes.push({ nodes.push({
id: id1, id: id1,
type: "math", type: "math",
position: { x, y: y + 35 }, position: [x, y + 35],
}); });
edges.push([id1, 0, parent, "b"]); edges.push([id1, 0, parent, "b"]);
} }

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -41,5 +41,8 @@ export class MemoryNodeRegistry implements NodeRegistry {
getNode(id: string): NodeType | undefined { getNode(id: string): NodeType | undefined {
return nodeTypes.find((nodeType) => nodeType.id === id); return nodeTypes.find((nodeType) => nodeType.id === id);
} }
getAllNodes(): NodeType[] {
return [...nodeTypes];
}
} }

View File

@ -1,4 +1,4 @@
import type { NodeInput, NodeInputType } from "./inputs"; import type { NodeInput } from "./inputs";
export type { NodeInput } from "./inputs"; export type { NodeInput } from "./inputs";
export type Node = { export type Node = {
@ -24,10 +24,7 @@ export type Node = {
title?: string; title?: string;
lastModified?: string; lastModified?: string;
}, },
position: { position: [x: number, y: number]
x: number;
y: number;
}
} }
export type NodeType = { export type NodeType = {
@ -49,6 +46,7 @@ export type Socket = {
export interface NodeRegistry { export interface NodeRegistry {
getNode: (id: string) => NodeType | undefined; getNode: (id: string) => NodeType | undefined;
getAllNodes: () => NodeType[];
} }
export interface RuntimeExecutor { export interface RuntimeExecutor {