feat: implement node sockets ui
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m22s
🚀 Lint & Test & Deploy / quality (pull_request) Failing after 1m6s
🚀 Lint & Test & Deploy / test-unit (pull_request) Failing after 43s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 2m5s
🚀 Lint & Test & Deploy / deploy (pull_request) Has been skipped
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m22s
🚀 Lint & Test & Deploy / quality (pull_request) Failing after 1m6s
🚀 Lint & Test & Deploy / test-unit (pull_request) Failing after 43s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 2m5s
🚀 Lint & Test & Deploy / deploy (pull_request) Has been skipped
This commit is contained in:
@@ -51,7 +51,7 @@ export class GraphManager extends EventEmitter<{
|
||||
id: number;
|
||||
nodes: SerializedNode[];
|
||||
edges: SerializedEdge[];
|
||||
cameraPosition: [number, number, number];
|
||||
nodeId: number; // group instance node id that was entered to reach the next level
|
||||
}[] = $state([]);
|
||||
|
||||
// ID of the currently active group, or null when at the root graph.
|
||||
@@ -642,11 +642,11 @@ export class GraphManager extends EventEmitter<{
|
||||
|
||||
isInsideGroup = $derived(this.currentGroupId !== null);
|
||||
|
||||
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
|
||||
enterGroup(nodeId: number): boolean {
|
||||
const groupNode = this.getNode(nodeId);
|
||||
if (!groupNode || groupNode.type !== '__internal/group/instance') return false;
|
||||
|
||||
log.log('entering group', { nodeId, cameraPosition });
|
||||
log.log('entering group', { nodeId });
|
||||
|
||||
const groupId = groupNode.props?.groupId as number;
|
||||
const group = this.getGroup(groupId);
|
||||
@@ -657,7 +657,7 @@ export class GraphManager extends EventEmitter<{
|
||||
id: this.currentGroupId ?? this.id,
|
||||
nodes: [...this.nodes.values()].map(n => serializeNode(n)),
|
||||
edges: [...this.edges.values()].map(e => serializeEdge(e)),
|
||||
cameraPosition
|
||||
nodeId
|
||||
});
|
||||
this.currentGroupId = groupId;
|
||||
|
||||
@@ -685,6 +685,8 @@ export class GraphManager extends EventEmitter<{
|
||||
this.history.reset();
|
||||
this.execute();
|
||||
this.save();
|
||||
|
||||
return { nodeId: parent.nodeId };
|
||||
}
|
||||
|
||||
createNodeId() {
|
||||
|
||||
@@ -365,7 +365,7 @@ export class GraphState {
|
||||
if (this.activeNodeId === -1) return;
|
||||
const node = this.graph.getNode(this.activeNodeId);
|
||||
if (!node || node.type !== '__internal/group/instance') return;
|
||||
const ok = this.graph.enterGroup(this.activeNodeId, [...this.cameraPosition]);
|
||||
const ok = this.graph.enterGroup(this.activeNodeId);
|
||||
if (ok) {
|
||||
this.activeNodeId = -1;
|
||||
this.clearSelection();
|
||||
@@ -375,7 +375,6 @@ export class GraphState {
|
||||
exitGroupNode() {
|
||||
const result = this.graph.exitGroup();
|
||||
if (!result) return;
|
||||
this.cameraPosition = result.camera;
|
||||
this.activeNodeId = result.nodeId;
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ type Color = { hue: number; saturation: number; lightness: number };
|
||||
|
||||
export class ColorGenerator {
|
||||
private colors: Map<string, Color> = new Map();
|
||||
private lightnessLevels = [10, 60];
|
||||
// private lightnessLevels = [10, 60];
|
||||
|
||||
constructor(predefined: Record<string, Color>) {
|
||||
for (const [id, colorStr] of Object.entries(predefined)) {
|
||||
@@ -10,6 +10,14 @@ export class ColorGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
public getColors() {
|
||||
return Object.fromEntries(
|
||||
this.colors.entries().map(([key, col]) => {
|
||||
return [key, this.colorToHsl(col)];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public getColor(id: string): string {
|
||||
if (this.colors.has(id)) {
|
||||
return this.colorToHsl(this.colors.get(id)!);
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
const rightBump = $derived(
|
||||
!!nodeType?.outputs?.length && node.type !== '__internal/group/input'
|
||||
);
|
||||
const cornerBottom = $derived(
|
||||
node.type === '__internal/group/input'
|
||||
? (Object.keys(nodeType?.inputs ?? {}).length ? 0 : 10)
|
||||
: node.type === '__internal/group/output'
|
||||
? (nodeType?.outputs?.length ? 0 : 10)
|
||||
: 0
|
||||
);
|
||||
|
||||
const aspectRatio = 0.25;
|
||||
|
||||
@@ -35,6 +42,7 @@
|
||||
height: 34,
|
||||
y: 49,
|
||||
cornerTop,
|
||||
cornerBottom,
|
||||
rightBump,
|
||||
aspectRatio
|
||||
})
|
||||
@@ -45,6 +53,7 @@
|
||||
height: 40,
|
||||
y: 49,
|
||||
cornerTop,
|
||||
cornerBottom,
|
||||
rightBump,
|
||||
aspectRatio
|
||||
})
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||
import { GraphState } from '$lib/graph-interface/graph-state.svelte';
|
||||
import type { NodeInstance } from '@nodarium/types';
|
||||
import InputSelect from '../../../../../packages/ui/src/lib/inputs/InputSelect.svelte';
|
||||
import { SocketTable } from '@nodarium/ui';
|
||||
import UnusedGroupsPanel from './UnusedGroupsPanel.svelte';
|
||||
|
||||
type Props = {
|
||||
manager: GraphManager;
|
||||
graphState: GraphState;
|
||||
node?: NodeInstance;
|
||||
};
|
||||
|
||||
const { manager, node = $bindable() }: Props = $props();
|
||||
const { manager, graphState, node = $bindable() }: Props = $props();
|
||||
|
||||
const activeGroup = $derived.by(() => {
|
||||
if (node?.type === '__internal/group/instance') {
|
||||
return manager.getGroup(node.props?.groupId as number);
|
||||
let group = manager.getGroup(node.props?.groupId as number);
|
||||
if (group) return group;
|
||||
}
|
||||
|
||||
if (manager?.isInsideGroup) {
|
||||
const activeGroupId = manager.parentStack?.at(-1)?.id;
|
||||
if (activeGroupId !== undefined) {
|
||||
return manager.getGroup(activeGroupId);
|
||||
}
|
||||
if (manager?.isInsideGroup && manager.currentGroupId !== null) {
|
||||
return manager.getGroup(manager.currentGroupId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +29,47 @@
|
||||
const name = (e.target as HTMLInputElement).value;
|
||||
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||
}
|
||||
|
||||
function handleRemoveInput(key: string) {
|
||||
if (!activeGroup) return;
|
||||
const group = manager.getGroup(activeGroup?.id);
|
||||
const inputs = $state.snapshot(group?.inputs ?? {});
|
||||
delete inputs[key];
|
||||
activeGroup.inputs = inputs;
|
||||
manager.nodes = manager.nodes;
|
||||
manager.save();
|
||||
}
|
||||
|
||||
const types = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
manager?.registry
|
||||
? manager.registry.getAllNodes()
|
||||
.flatMap(n =>
|
||||
Object.values(n.inputs ?? {})
|
||||
.map(v => v.type)
|
||||
)
|
||||
: []
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
let outputType = $derived(activeGroup?.outputs?.[0]?.type ?? 'unknown');
|
||||
|
||||
$effect(() => {
|
||||
if (!activeGroup) return;
|
||||
const group = manager.getGroup(activeGroup?.id);
|
||||
const outputs = $state.snapshot(group?.outputs ?? []);
|
||||
if (outputs?.[0]?.type === outputType) return;
|
||||
activeGroup.outputs = [
|
||||
{
|
||||
label: outputs[0]?.label ?? 'Output',
|
||||
type: outputType
|
||||
}
|
||||
];
|
||||
manager.nodes = manager.nodes;
|
||||
manager.save();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if activeGroup}
|
||||
@@ -39,7 +80,7 @@
|
||||
|
||||
{#if activeGroup}
|
||||
{#key activeGroup.id}
|
||||
<div class="group-settings">
|
||||
<div class="p-4 group-settings">
|
||||
<label for="group-name">Group name</label>
|
||||
<input
|
||||
id="group-name"
|
||||
@@ -51,18 +92,33 @@
|
||||
|
||||
<label for="group-name">Group Inputs</label>
|
||||
<div>
|
||||
{#each Object.keys(activeGroup?.inputs ?? {}) as key (key)}
|
||||
<div class="flex">
|
||||
<InputSelect
|
||||
value={activeGroup.inputs?.[key].type}
|
||||
options={['seed', 'float', 'boolean']}
|
||||
/>
|
||||
<input type="text" placeholder="Input {key}" />
|
||||
<button>
|
||||
🥊
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<SocketTable
|
||||
{types}
|
||||
onremove={handleRemoveInput}
|
||||
bind:inputs={activeGroup.inputs}
|
||||
colors={graphState?.colors?.getColors()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label for="group-name mb-2">Group output</label>
|
||||
<div class="flex bg-layer-2 rounded-sm outline outline-outline w-min">
|
||||
<span
|
||||
style:background={graphState?.colors?.getColor(outputType)}
|
||||
class="block opacity-50 min-w-2 ml-2 w-2 h-2 my-auto rounded-sm"
|
||||
></span>
|
||||
<select
|
||||
class="text-[0.9em] shrink-0 px-2 py-1 border-outline"
|
||||
bind:value={outputType}
|
||||
>
|
||||
{#each types as type (type)}
|
||||
<option>
|
||||
<span
|
||||
style="background: {graphState?.colors?.getColor(type)}; width: 5px; height: 5px; border-radius: 5px;"
|
||||
></span>
|
||||
{type}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
@@ -77,10 +133,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.group-settings label {
|
||||
label {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</script>
|
||||
|
||||
{#if unusedTree.length}
|
||||
<div class="panel">
|
||||
<div class="panel p-4">
|
||||
<div class="header">
|
||||
<span>Unused groups</span>
|
||||
<button class="remove-all" onclick={() => manager.removeUnusedGroups()}>
|
||||
@@ -78,8 +78,9 @@
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
border-top: 1px solid var(--color-outline);
|
||||
margin-top: -1px;
|
||||
border-bottom: 1px solid var(--color-outline);
|
||||
padding: 0.75em 1em;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
/>
|
||||
{#key activeNode}
|
||||
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
||||
<GroupSettings {manager} bind:node={activeNode} />
|
||||
<GroupSettings graphState={graphInterface?.state} {manager} bind:node={activeNode} />
|
||||
{/key}
|
||||
</Panel>
|
||||
<Panel
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}active");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}selected");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}outline{!,}");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,border-,divide-}outline{!,}");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}connection");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}text");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export { default as InputNumber } from './inputs/InputNumber.svelte';
|
||||
export { default as InputSelect } from './inputs/InputSelect.svelte';
|
||||
export { default as InputShape } from './inputs/InputShape.svelte';
|
||||
export { default as InputVec3 } from './inputs/InputVec3.svelte';
|
||||
export { default as SocketTable } from './inputs/SocketTable.svelte';
|
||||
|
||||
export { default as Details } from './Details.svelte';
|
||||
export { default as JsonViewer } from './JsonViewer.svelte';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { NodeInput } from '@nodarium/types';
|
||||
type Props = {
|
||||
inputs?: Record<string, NodeInput>;
|
||||
colors: Record<string, string>;
|
||||
onremove?: (key: string) => void;
|
||||
types: string[];
|
||||
};
|
||||
let { inputs = $bindable(), onremove, colors = {}, types = ['seed', 'float', 'path'] }: Props =
|
||||
$props();
|
||||
|
||||
let potentialRow = $state<
|
||||
{
|
||||
type: string;
|
||||
label: string;
|
||||
} | undefined
|
||||
>();
|
||||
|
||||
function showPotentialRow() {
|
||||
potentialRow = {
|
||||
type: types[0],
|
||||
label: 'Input ' + Object.keys(inputs ?? {}).length
|
||||
};
|
||||
}
|
||||
|
||||
function realizePotentialRow() {
|
||||
if (inputs) inputs[`input_${Object.keys(inputs).length}`] = potentialRow as NodeInput;
|
||||
potentialRow = undefined;
|
||||
}
|
||||
|
||||
function removeRow(key?: string) {
|
||||
if (!key) {
|
||||
potentialRow = undefined;
|
||||
} else if (inputs) {
|
||||
onremove?.(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getColor(type: string) {
|
||||
if (type in colors) {
|
||||
return colors[type];
|
||||
}
|
||||
|
||||
return '#f00';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet row(input: { type: string; label?: string }, remove: () => void, add?: () => void)}
|
||||
<div class="flex min-w-0">
|
||||
<span
|
||||
style:background={getColor(input.type)}
|
||||
data-type={input.type}
|
||||
class="block opacity-50 min-w-2 ml-2 w-2 h-2 my-auto rounded-sm"
|
||||
></span>
|
||||
<select
|
||||
class="text-[0.9em] border-r w-19 shrink-0 px-2 py-1 border-outline"
|
||||
bind:value={input.type}
|
||||
>
|
||||
{#each types as type (type)}
|
||||
<option>
|
||||
<span
|
||||
style="background: {getColor(type)}; width: 5px; height: 5px; border-radius: 5px;"
|
||||
></span>
|
||||
{type}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
class="px-2 grow min-w-30 border-r border-outline text-[0.9em]"
|
||||
type="text"
|
||||
bind:value={input.label}
|
||||
/>
|
||||
<button
|
||||
class="px-2 cursor-pointer opacity-50 hover:opacity-100 hover:bg-red-400"
|
||||
onclick={remove}
|
||||
aria-label="remove"
|
||||
>
|
||||
{#if add}
|
||||
<span class="py-1 block i-[tabler--cancel]"></span>
|
||||
{:else}
|
||||
<span class="py-1 block i-[tabler--trash]"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if add}
|
||||
<button
|
||||
class="px-2 border-l hover:bg-green-300 opacity-50 hover:opacity-100 hover:text-layer-1 border-outline cursor-pointer"
|
||||
onclick={add}
|
||||
aria-label="add"
|
||||
>
|
||||
<span class="py-1 block i-[tabler--circle-plus]"></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="rounded-sm overflow-hidden bg-layer-2 divide-y divide-outline outline-1 outline-outline">
|
||||
{#each Object.entries(inputs ?? {}) as [key, input] (key)}
|
||||
{@render row(input, () => removeRow(key))}
|
||||
{/each}
|
||||
{#if potentialRow}
|
||||
<div class="opacity-80">
|
||||
{@render row(potentialRow, () => removeRow(), () => realizePotentialRow())}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="opacity-40">
|
||||
<div class="flex h-[27px]">
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="border-l hover:bg-green-300 hover:text-layer-1 border-outline py-1 px-2 cursor-pointer"
|
||||
onclick={() => showPotentialRow()}
|
||||
aria-label="remove"
|
||||
>
|
||||
<span class="block i-[tabler--circle-plus]"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
JsonViewer,
|
||||
ShortCut
|
||||
} from '$lib';
|
||||
import SocketTable from '$lib/inputs/SocketTable.svelte';
|
||||
import Section from './Section.svelte';
|
||||
import Theme from './Theme.svelte';
|
||||
import ThemeSelector from './ThemeSelector.svelte';
|
||||
@@ -38,6 +39,17 @@
|
||||
settings: { seed: 42, enabled: true }
|
||||
});
|
||||
|
||||
let socketTypes = $state({
|
||||
input_0: {
|
||||
'label': 'Input 0',
|
||||
'type': 'path'
|
||||
},
|
||||
input_1: {
|
||||
'label': 'Input 1',
|
||||
'type': 'float'
|
||||
}
|
||||
});
|
||||
|
||||
function randomlyUpdateJson() {
|
||||
const rand = Math.floor(Math.random() * 5);
|
||||
if (rand === 0) {
|
||||
@@ -150,6 +162,18 @@
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Socket Table">
|
||||
<SocketTable
|
||||
colors={{
|
||||
seed: '#f00',
|
||||
float: '#0f0',
|
||||
path: '#00f'
|
||||
}}
|
||||
types={['seed', 'float', 'path']}
|
||||
bind:inputs={socketTypes}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Shortcut">
|
||||
<div class="flex gap-4">
|
||||
<ShortCut ctrl key="S" />
|
||||
|
||||
@@ -1,3 +1,60 @@
|
||||
interface LogEntry {
|
||||
time: string;
|
||||
scope: string;
|
||||
level: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
const logBuffer: LogEntry[] = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
function formatTime(): string {
|
||||
const ms = Date.now() - startTime;
|
||||
const h = Math.floor(ms / 3600000).toString().padStart(2, '0');
|
||||
const m = Math.floor((ms % 3600000) / 60000).toString().padStart(2, '0');
|
||||
const s = Math.floor((ms % 60000) / 1000).toString().padStart(2, '0');
|
||||
const mss = (ms % 1000).toString().padStart(3, '0');
|
||||
return `${h}:${m}:${s}.${mss}`;
|
||||
}
|
||||
|
||||
function serialize(arg: unknown): string {
|
||||
if (typeof arg === 'string') return arg;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
|
||||
function formatEntry(entry: LogEntry, scopeWidth: number): string {
|
||||
const scope = `[${entry.scope}]`.padEnd(scopeWidth + 2);
|
||||
const level = entry.level.toUpperCase().padEnd(5);
|
||||
const msg = entry.args.map(serialize).join(' ');
|
||||
return `${entry.time} ${scope} ${level} ${msg}`;
|
||||
}
|
||||
|
||||
(globalThis as Record<string, unknown>).copyLogs = () => {
|
||||
if (logBuffer.length === 0) {
|
||||
console.log('%c[logger] No log entries to copy', 'color: #888');
|
||||
return;
|
||||
}
|
||||
const scopeWidth = logBuffer.reduce((max, e) => Math.max(max, e.scope.length), 0);
|
||||
const lines = [
|
||||
`=== Log Export (${logBuffer.length} entries) ===`,
|
||||
'',
|
||||
...logBuffer.map(e => formatEntry(e, scopeWidth))
|
||||
].join('\n');
|
||||
|
||||
navigator.clipboard.writeText(lines).then(() => {
|
||||
console.log(`%c[logger] Copied ${logBuffer.length} entries to clipboard`, 'color: #4f4');
|
||||
});
|
||||
};
|
||||
|
||||
(globalThis as Record<string, unknown>).clearLogs = () => {
|
||||
logBuffer.length = 0;
|
||||
console.log('%c[logger] Log buffer cleared', 'color: #888');
|
||||
};
|
||||
|
||||
export const createLogger = (() => {
|
||||
let maxLength = 5;
|
||||
return (scope: string) => {
|
||||
@@ -6,18 +63,35 @@ export const createLogger = (() => {
|
||||
|
||||
let isGrouped = false;
|
||||
|
||||
function s(color: string, ...args: any) {
|
||||
function s(color: string, ...args: unknown[]) {
|
||||
return isGrouped
|
||||
? [...args]
|
||||
: [`[%c${scope.padEnd(maxLength, ' ')}]:`, `color: ${color}`, ...args];
|
||||
}
|
||||
|
||||
function record(level: string, args: unknown[]) {
|
||||
logBuffer.push({ time: formatTime(), scope, level, args });
|
||||
}
|
||||
|
||||
return {
|
||||
log: (...args: any[]) => !muted && console.log(...s('#888', ...args)),
|
||||
info: (...args: any[]) => !muted && console.info(...s('#888', ...args)),
|
||||
warn: (...args: any[]) => !muted && console.warn(...s('#888', ...args)),
|
||||
error: (...args: any[]) => console.error(...s('#f88', ...args)),
|
||||
group: (...args: any[]) => {
|
||||
log: (...args: unknown[]) => {
|
||||
record('log', args);
|
||||
!muted && console.log(...s('#888', ...args));
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
record('info', args);
|
||||
!muted && console.info(...s('#888', ...args));
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
record('warn', args);
|
||||
!muted && console.warn(...s('#888', ...args));
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
record('error', args);
|
||||
console.error(...s('#f88', ...args));
|
||||
},
|
||||
group: (...args: unknown[]) => {
|
||||
record('group', args);
|
||||
if (!muted) {
|
||||
console.groupCollapsed(...s('#888', ...args));
|
||||
isGrouped = true;
|
||||
|
||||
Reference in New Issue
Block a user