feat: add node group breadcrumbs
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { getGraphManager } from '../graph-state.svelte';
|
||||
const graph = getGraphManager();
|
||||
|
||||
function getGroupName(groupId: number) {
|
||||
const group = graph.getGroup(groupId);
|
||||
return group?.name || `Group#${groupId}`;
|
||||
}
|
||||
|
||||
function exitToGroup(groupId?: number) {
|
||||
while (graph.graphStack.length > 0 && graph.graphStack.at(-1)?.groupId !== groupId) {
|
||||
graph.exitGroup();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shadow" class:is-inside-group={graph.isInsideGroup}></div>
|
||||
|
||||
{#if graph.isInsideGroup}
|
||||
<div class="group-name flex gap-1 items-center">
|
||||
<button
|
||||
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
|
||||
onclick={() => exitToGroup()}
|
||||
>
|
||||
Root
|
||||
</button>
|
||||
{#each graph.graphStack as group (group.groupId)}
|
||||
<span class="i-[tabler--arrow-right]"></span>
|
||||
<button
|
||||
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
|
||||
onclick={() => exitToGroup(group.groupId)}
|
||||
>
|
||||
{getGroupName(group.groupId)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shadow {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
right: calc(var(--padding-right) - 5px);
|
||||
bottom: -5px;
|
||||
z-index: 1;
|
||||
transition: box-shadow 0.3s ease;
|
||||
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shadow.is-inside-group {
|
||||
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
position: absolute;
|
||||
left: calc(50% - var(--padding-right) / 2);
|
||||
transition: left 0.3s ease;
|
||||
top: 12px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { clone } from '$lib/helpers';
|
||||
import throttle from '$lib/helpers/throttle';
|
||||
import { RemoteNodeRegistry } from '$lib/node-registry/index';
|
||||
import type {
|
||||
@@ -16,6 +17,12 @@ import { fastHashString } from '@nodarium/utils';
|
||||
import { createLogger } from '@nodarium/utils';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import EventEmitter from './helpers/EventEmitter';
|
||||
import {
|
||||
areEdgesEqual,
|
||||
areSocketsCompatible,
|
||||
serializeEdge,
|
||||
serializeNode
|
||||
} from './helpers/nodeHelpers';
|
||||
import { HistoryManager } from './history-manager';
|
||||
|
||||
const logger = createLogger('graph-manager');
|
||||
@@ -23,41 +30,6 @@ logger.mute();
|
||||
|
||||
const remoteRegistry = new RemoteNodeRegistry('');
|
||||
|
||||
const clone = 'structuredClone' in globalThis
|
||||
? globalThis.structuredClone
|
||||
: (args: unknown) => JSON.parse(JSON.stringify(args));
|
||||
|
||||
function areSocketsCompatible(
|
||||
output: string | undefined,
|
||||
inputs: string | (string | undefined)[] | undefined
|
||||
) {
|
||||
if (output === '*') return true;
|
||||
if (Array.isArray(inputs) && output) {
|
||||
return inputs.includes('*') || inputs.includes(output);
|
||||
}
|
||||
return inputs === output;
|
||||
}
|
||||
|
||||
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
|
||||
if (firstEdge[0].id !== secondEdge[0].id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstEdge[1] !== secondEdge[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstEdge[2].id !== secondEdge[2].id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstEdge[3] !== secondEdge[3]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export class GraphManager extends EventEmitter<{
|
||||
save: Graph;
|
||||
result: unknown;
|
||||
@@ -129,26 +101,11 @@ export class GraphManager extends EventEmitter<{
|
||||
}
|
||||
|
||||
serialize(): Graph {
|
||||
const nodes = Array.from(this.nodes.values()).map((node) => ({
|
||||
id: node.id,
|
||||
position: [...node.position],
|
||||
type: node.type,
|
||||
props: node.props
|
||||
})) as NodeInstance[];
|
||||
const edges = this.edges.map((edge) => [
|
||||
edge[0].id,
|
||||
edge[1],
|
||||
edge[2].id,
|
||||
edge[3]
|
||||
]) as Graph['edges'];
|
||||
const nodes = Array.from(this.nodes.values()).map((node) => serializeNode(node));
|
||||
const edges = this.edges.map((edge) => serializeEdge(edge));
|
||||
|
||||
const groups = this.graph.groups?.map((group) => {
|
||||
const groupNodes = group.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
position: [...node.position],
|
||||
type: node.type,
|
||||
props: node.props
|
||||
})) as NodeInstance[];
|
||||
const groupNodes = group.nodes.map((node) => serializeNode(node));
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
@@ -814,12 +771,39 @@ export class GraphManager extends EventEmitter<{
|
||||
return nodes;
|
||||
}
|
||||
|
||||
getUnusedGroups() {
|
||||
const usedGroupIds = new SvelteSet<number>();
|
||||
const queue: number[] = [];
|
||||
|
||||
for (const node of this.nodes.values()) {
|
||||
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||
queue.push(node.props.groupId as number);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length) {
|
||||
const groupId = queue.pop()!;
|
||||
if (usedGroupIds.has(groupId)) continue;
|
||||
usedGroupIds.add(groupId);
|
||||
const group = this.getGroup(groupId);
|
||||
if (!group) continue;
|
||||
for (const node of group.nodes) {
|
||||
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||
const childId = node.props.groupId as number;
|
||||
if (!usedGroupIds.has(childId)) queue.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.graph.groups.filter(g => !usedGroupIds.has(g.id));
|
||||
}
|
||||
|
||||
removeUnusedGroups() {
|
||||
const usedGroups = new SvelteSet(this.getAllNodes().map(n => n.props?.groupId));
|
||||
const unusedGroupAmount = this.graph.groups.length - usedGroups.size;
|
||||
this.graph.groups = this.graph.groups.filter(g => usedGroups.has(g.id));
|
||||
const unused = this.getUnusedGroups();
|
||||
const unusedIds = new SvelteSet(unused.map(g => g.id));
|
||||
this.graph.groups = this.graph.groups.filter(g => !unusedIds.has(g.id));
|
||||
this.save();
|
||||
return unusedGroupAmount;
|
||||
return unused.length;
|
||||
}
|
||||
|
||||
groupNodes(nodeIds: number[]) {
|
||||
@@ -863,10 +847,14 @@ export class GraphManager extends EventEmitter<{
|
||||
inputs[`input_${i}`] = input as NodeInput;
|
||||
});
|
||||
|
||||
const outputs = [groupOutputs.values().next().value!].map((edge, i) => ({
|
||||
label: `Output ${i}`,
|
||||
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
|
||||
}));
|
||||
const outputs = [];
|
||||
if (groupOutputs.size) {
|
||||
const edge = groupOutputs.values().next().value!;
|
||||
outputs.push({
|
||||
label: `Output`,
|
||||
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
|
||||
});
|
||||
}
|
||||
|
||||
const groupPosition = [0, 0] as [number, number];
|
||||
const bounds: Box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import AddMenu from '../components/AddMenu.svelte';
|
||||
import BoxSelection from '../components/BoxSelection.svelte';
|
||||
import Camera from '../components/Camera.svelte';
|
||||
import GroupBreadcrumps from '../components/GroupBreadcrumps.svelte';
|
||||
import HelpView from '../components/HelpView.svelte';
|
||||
import Debug from '../debug/Debug.svelte';
|
||||
import EdgeEl from '../edges/Edge.svelte';
|
||||
@@ -109,13 +110,15 @@
|
||||
return nodeType?.outputs?.[index] || 'unknown';
|
||||
}
|
||||
|
||||
function getGroupName() {
|
||||
const groupId = graph.graphStack.at(-1)?.groupId;
|
||||
if (groupId !== undefined) {
|
||||
const group = graph.getGroup(groupId);
|
||||
return group?.name || `Group#${groupId}`;
|
||||
let groupSize = 0;
|
||||
$effect(() => {
|
||||
if (graph.graph.groups.length > groupSize) {
|
||||
groupSize = graph.graph.groups.length;
|
||||
}
|
||||
}
|
||||
if (graph.graph.groups.length < groupSize) {
|
||||
console.error('We have lost a group!');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@@ -142,7 +145,6 @@
|
||||
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
||||
{...fileDropEvents.getEventListenerProps()}
|
||||
>
|
||||
<div class="shadow"></div>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/wasm,application/json"
|
||||
@@ -153,17 +155,7 @@
|
||||
/>
|
||||
<label for="drop-zone"></label>
|
||||
|
||||
{#if graph.isInsideGroup}
|
||||
<button
|
||||
class="exit-group flex items-center gap-1 p-1 text-sm px-2 bg-layer-2"
|
||||
onclick={() => graphState.exitGroupNode()}
|
||||
>
|
||||
<span class="i-[tabler--arrow-left]"></span>Exit Group
|
||||
</button>
|
||||
<p class="group-name absolute">
|
||||
Group <b>{getGroupName()}</b>
|
||||
</p>
|
||||
{/if}
|
||||
<GroupBreadcrumps />
|
||||
|
||||
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
||||
<Camera
|
||||
@@ -273,30 +265,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
position: absolute;
|
||||
left: calc(50% - var(--padding-right) / 2);
|
||||
transition: left 0.3s ease;
|
||||
top: 12px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.exit-group {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.exit-group:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
@@ -308,22 +276,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
right: calc(var(--padding-right) - 5px);
|
||||
bottom: -5px;
|
||||
z-index: 1;
|
||||
transition: box-shadow 0.3s ease;
|
||||
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-inside-group .shadow {
|
||||
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
|
||||
}
|
||||
|
||||
.is-panning {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { NodeDefinition } from '@nodarium/types';
|
||||
import type {
|
||||
Edge,
|
||||
NodeDefinition,
|
||||
NodeInstance,
|
||||
SerializedEdge,
|
||||
SerializedNode
|
||||
} from '@nodarium/types';
|
||||
|
||||
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
||||
if (node.id === '__internal/group/input') {
|
||||
@@ -27,6 +33,19 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
export function serializeNode(node: SerializedNode | NodeInstance): SerializedNode {
|
||||
return {
|
||||
id: node.id,
|
||||
position: [...node.position],
|
||||
type: node.type,
|
||||
props: node.props
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEdge(edge: Edge): SerializedEdge {
|
||||
return [edge[0].id, edge[1], edge[2].id, edge[3]];
|
||||
}
|
||||
|
||||
const nodeHeightCache: Record<string, number> = {};
|
||||
export function getNodeHeight(node: NodeDefinition) {
|
||||
if (!node || !('inputs' in node)) {
|
||||
@@ -45,3 +64,34 @@ export function getNodeHeight(node: NodeDefinition) {
|
||||
nodeHeightCache[node.id] = height;
|
||||
return height;
|
||||
}
|
||||
|
||||
export function areSocketsCompatible(
|
||||
output: string | undefined,
|
||||
inputs: string | (string | undefined)[] | undefined
|
||||
) {
|
||||
if (output === '*') return true;
|
||||
if (Array.isArray(inputs) && output) {
|
||||
return inputs.includes('*') || inputs.includes(output);
|
||||
}
|
||||
return inputs === output;
|
||||
}
|
||||
|
||||
export function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
|
||||
if (firstEdge[0].id !== secondEdge[0].id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstEdge[1] !== secondEdge[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstEdge[2].id !== secondEdge[2].id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstEdge[3] !== secondEdge[3]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||
import type { NodeInstance } from '@nodarium/types';
|
||||
import InputSelect from '../../../../../packages/ui/src/lib/inputs/InputSelect.svelte';
|
||||
import UnusedGroupsPanel from './UnusedGroupsPanel.svelte';
|
||||
|
||||
type Props = {
|
||||
manager: GraphManager;
|
||||
@@ -28,18 +29,9 @@
|
||||
const name = (e.target as HTMLInputElement).value;
|
||||
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||
}
|
||||
|
||||
const hasUnusedGroups = $derived.by(() => {
|
||||
if (!manager) return false;
|
||||
if (manager.isInsideGroup) return false;
|
||||
if (manager.graph.groups.length === 0) return false;
|
||||
return manager.graph.groups.filter(g => {
|
||||
return !manager.graph.nodes.find(n => n.props?.groupId === g.id);
|
||||
}).length;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if activeGroup || hasUnusedGroups}
|
||||
{#if activeGroup}
|
||||
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
||||
<h3>Group Settings</h3>
|
||||
</div>
|
||||
@@ -76,12 +68,8 @@
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#if hasUnusedGroups}
|
||||
<div class="group-actions">
|
||||
<button onclick={() => manager.removeUnusedGroups()}>
|
||||
Remove ({hasUnusedGroups}) orphaned groups
|
||||
</button>
|
||||
</div>
|
||||
{#if manager && !manager.isInsideGroup}
|
||||
<UnusedGroupsPanel {manager} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -110,26 +98,4 @@
|
||||
.group-settings input:focus {
|
||||
outline: 1px solid var(--color-active);
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
padding: 0.75em 1em;
|
||||
border-bottom: 1px solid var(--color-outline);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.group-actions button {
|
||||
width: 100%;
|
||||
padding: 0.4em 0.8em;
|
||||
background: var(--color-layer-1);
|
||||
border: 1px solid var(--color-outline);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-actions button:hover {
|
||||
border-color: var(--color-active);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||
import type { GroupDefinition } from '@nodarium/types';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type Props = { manager: GraphManager };
|
||||
const { manager }: Props = $props();
|
||||
|
||||
type GroupNode = { group: GroupDefinition; children: GroupNode[] };
|
||||
|
||||
const unusedTree = $derived.by((): GroupNode[] => {
|
||||
const unused = manager.getUnusedGroups();
|
||||
if (!unused.length) return [];
|
||||
|
||||
const unusedIds = new Set(unused.map(g => g.id));
|
||||
|
||||
// Build child map: which unused groups reference which other unused groups
|
||||
const childrenOf = new SvelteMap<number, number[]>();
|
||||
const referencedBy = new SvelteSet<number>();
|
||||
|
||||
for (const group of unused) {
|
||||
const refs: number[] = [];
|
||||
for (const node of group.nodes) {
|
||||
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||
const childId = node.props.groupId as number;
|
||||
if (unusedIds.has(childId)) {
|
||||
refs.push(childId);
|
||||
referencedBy.add(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
childrenOf.set(group.id, refs);
|
||||
}
|
||||
|
||||
const byId = new Map(unused.map(g => [g.id, g]));
|
||||
|
||||
function buildNode(g: GroupDefinition): GroupNode {
|
||||
return {
|
||||
group: g,
|
||||
children: (childrenOf.get(g.id) ?? []).map(id => buildNode(byId.get(id)!))
|
||||
};
|
||||
}
|
||||
|
||||
return unused
|
||||
.filter(g => !referencedBy.has(g.id))
|
||||
.map(buildNode);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if unusedTree.length}
|
||||
<div class="panel">
|
||||
<div class="header">
|
||||
<span>Unused groups</span>
|
||||
<button class="remove-all" onclick={() => manager.removeUnusedGroups()}>
|
||||
Remove all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="tree">
|
||||
{#snippet treeNode(node: GroupNode)}
|
||||
<li>
|
||||
<span class="group-name">{node.group.name || `Group #${node.group.id}`}</span>
|
||||
{#if node.children.length}
|
||||
<ul>
|
||||
{#each node.children as child (child.group.id)}
|
||||
{@render treeNode(child)}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
{#each unusedTree as node (node.group.id)}
|
||||
{@render treeNode(node)}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
border-bottom: 1px solid var(--color-outline);
|
||||
padding: 0.75em 1em;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.remove-all {
|
||||
background: none;
|
||||
border: 1px solid var(--color-outline);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family);
|
||||
font-size: 0.85em;
|
||||
padding: 0.2em 0.5em;
|
||||
}
|
||||
|
||||
.remove-all:hover {
|
||||
border-color: var(--color-active);
|
||||
}
|
||||
|
||||
.tree {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
border-left: 1px solid var(--color-outline);
|
||||
}
|
||||
|
||||
.tree li {
|
||||
padding: 0.15em 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.tree ul .group-name::before {
|
||||
content: '└ ';
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user