create-substrate 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,17 +8,16 @@ export const nodeEditor = {
8
8
  "colour-picker",
9
9
  "status-bar",
10
10
  "zoom-controls",
11
- "command-palette",
12
11
  ],
13
12
  cssVars: ` /* Node port colours */
14
13
  --node-port-number: oklch(0.7 0.15 250);
15
- --node-port-string: oklch(0.7 0.15 150);
16
- --node-port-boolean: oklch(0.7 0.15 30);
17
14
  --node-port-colour: oklch(0.7 0.15 330);
18
- --node-port-vector: oklch(0.7 0.15 200);
19
15
  --node-port-image: oklch(0.7 0.15 100);
16
+ --node-port-geometry: oklch(0.7 0.15 200);
17
+ --node-port-text: oklch(0.7 0.15 150);
18
+ --node-port-boolean: oklch(0.7 0.15 30);
20
19
  --node-port-any: oklch(0.6 0 0);`,
21
- app: () => `import { useState, useCallback } from "react"
20
+ app: () => `import { useEffect, useRef, useState } from "react"
22
21
  import {
23
22
  Toolbar,
24
23
  ToolbarToggleGroup,
@@ -33,151 +32,585 @@ import {
33
32
  PaneContent,
34
33
  } from "@/components/substrate/pane"
35
34
  import {
36
- Node,
35
+ NodeCard,
37
36
  NodeHeader,
38
37
  NodeTitle,
39
- NodeContent,
38
+ NodeBadge,
39
+ NodePorts,
40
40
  NodePort,
41
+ NodePreview,
41
42
  NodeDivider,
43
+ portColourVar,
44
+ type PortType,
42
45
  } from "@/components/substrate/node"
43
46
  import { NumberInput } from "@/components/substrate/number-input"
44
47
  import { ColourPicker } from "@/components/substrate/colour-picker"
45
- import { StatusBar, StatusBarSection, StatusBarItem } from "@/components/substrate/status-bar"
48
+ import {
49
+ StatusBar,
50
+ StatusBarSection,
51
+ StatusBarItem,
52
+ } from "@/components/substrate/status-bar"
46
53
  import { ZoomControls } from "@/components/substrate/zoom-controls"
47
- import { RiCursorLine, RiHand } from "@remixicon/react"
54
+ import { RiAddLine, RiCursorLine, RiDeleteBinLine, RiHand } from "@remixicon/react"
55
+
56
+ // ── Graph model ──
57
+
58
+ type NodeKind = "number" | "add" | "colour" | "mix" | "output"
48
59
 
49
- interface NodeData {
60
+ interface PortSpec {
50
61
  id: string
51
- type: "math" | "colour"
52
62
  label: string
63
+ type: PortType
64
+ }
65
+
66
+ interface KindSpec {
67
+ title: string
68
+ badge: string
69
+ inputs: PortSpec[]
70
+ output: PortSpec | null
71
+ }
72
+
73
+ const KINDS: Record<NodeKind, KindSpec> = {
74
+ number: {
75
+ title: "Number",
76
+ badge: "Input",
77
+ inputs: [],
78
+ output: { id: "out", label: "Value", type: "number" },
79
+ },
80
+ add: {
81
+ title: "Add",
82
+ badge: "Math",
83
+ inputs: [
84
+ { id: "a", label: "A", type: "number" },
85
+ { id: "b", label: "B", type: "number" },
86
+ ],
87
+ output: { id: "out", label: "Result", type: "number" },
88
+ },
89
+ colour: {
90
+ title: "Colour",
91
+ badge: "Input",
92
+ inputs: [],
93
+ output: { id: "out", label: "Colour", type: "colour" },
94
+ },
95
+ mix: {
96
+ title: "Mix",
97
+ badge: "Colour",
98
+ inputs: [
99
+ { id: "a", label: "Colour A", type: "colour" },
100
+ { id: "b", label: "Colour B", type: "colour" },
101
+ { id: "factor", label: "Factor", type: "number" },
102
+ ],
103
+ output: { id: "out", label: "Result", type: "colour" },
104
+ },
105
+ output: {
106
+ title: "Output",
107
+ badge: "Sink",
108
+ inputs: [{ id: "value", label: "Value", type: "any" }],
109
+ output: null,
110
+ },
111
+ }
112
+
113
+ const ADDABLE: NodeKind[] = ["number", "add", "colour", "mix", "output"]
114
+
115
+ interface GraphNode {
116
+ id: string
117
+ kind: NodeKind
53
118
  x: number
54
119
  y: number
120
+ /** Used by "number" nodes */
121
+ value: number
122
+ /** Used by "colour" nodes */
123
+ colour: string
55
124
  }
56
125
 
57
- const INITIAL_NODES: NodeData[] = [
58
- { id: "math-1", type: "math", label: "Add", x: 80, y: 100 },
59
- { id: "colour-1", type: "colour", label: "Mix Colour", x: 400, y: 80 },
126
+ interface Connection {
127
+ from: string
128
+ to: string
129
+ input: string
130
+ }
131
+
132
+ type Value = number | string | null
133
+
134
+ const INITIAL_NODES: GraphNode[] = [
135
+ { id: "colour-1", kind: "colour", x: 60, y: 60, value: 0, colour: "#6366f1" },
136
+ { id: "colour-2", kind: "colour", x: 60, y: 180, value: 0, colour: "#f59e0b" },
137
+ { id: "number-1", kind: "number", x: 60, y: 300, value: 0.2, colour: "#ffffff" },
138
+ { id: "number-2", kind: "number", x: 60, y: 420, value: 0.3, colour: "#ffffff" },
139
+ { id: "add-1", kind: "add", x: 360, y: 330, value: 0, colour: "#ffffff" },
140
+ { id: "mix-1", kind: "mix", x: 660, y: 100, value: 0, colour: "#ffffff" },
141
+ { id: "output-1", kind: "output", x: 960, y: 100, value: 0, colour: "#ffffff" },
60
142
  ]
61
143
 
62
- function MathNode({ node, selected, onSelect }: { node: NodeData; selected: boolean; onSelect: () => void }) {
63
- return (
64
- <div
65
- className="absolute"
66
- style={{ left: node.x, top: node.y }}
67
- onClick={onSelect}
68
- >
69
- <Node data-selected={selected || undefined}>
70
- <NodeHeader>
71
- <NodeTitle>{node.label}</NodeTitle>
72
- </NodeHeader>
73
- <NodeContent>
74
- <NodePort type="number" direction="input" label="A" />
75
- <NodePort type="number" direction="input" label="B" />
76
- <NodeDivider />
77
- <NodePort type="number" direction="output" label="Result" />
78
- </NodeContent>
79
- </Node>
80
- </div>
81
- )
144
+ const INITIAL_CONNECTIONS: Connection[] = [
145
+ { from: "colour-1", to: "mix-1", input: "a" },
146
+ { from: "colour-2", to: "mix-1", input: "b" },
147
+ { from: "number-1", to: "add-1", input: "a" },
148
+ { from: "number-2", to: "add-1", input: "b" },
149
+ { from: "add-1", to: "mix-1", input: "factor" },
150
+ { from: "mix-1", to: "output-1", input: "value" },
151
+ ]
152
+
153
+ // ── Evaluation ──
154
+
155
+ function parseHex(hex: string): [number, number, number] {
156
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(hex)
157
+ if (!match) return [0, 0, 0]
158
+ return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)]
159
+ }
160
+
161
+ function mixColours(a: string, b: string, factor: number): string {
162
+ const t = Math.min(1, Math.max(0, factor))
163
+ const from = parseHex(a)
164
+ const to = parseHex(b)
165
+ const channels = from.map((channel, i) => Math.round(channel + (to[i] - channel) * t))
166
+ return "#" + channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")
167
+ }
168
+
169
+ function evaluate(id: string, nodes: GraphNode[], connections: Connection[]): Value {
170
+ const node = nodes.find((n) => n.id === id)
171
+ if (!node) return null
172
+
173
+ const input = (port: string): Value => {
174
+ const connection = connections.find((c) => c.to === id && c.input === port)
175
+ return connection ? evaluate(connection.from, nodes, connections) : null
176
+ }
177
+ const numberInput = (port: string, fallback: number) => {
178
+ const value = input(port)
179
+ return typeof value === "number" ? value : fallback
180
+ }
181
+ const colourInput = (port: string, fallback: string) => {
182
+ const value = input(port)
183
+ return typeof value === "string" ? value : fallback
184
+ }
185
+
186
+ switch (node.kind) {
187
+ case "number":
188
+ return node.value
189
+ case "colour":
190
+ return node.colour
191
+ case "add":
192
+ return numberInput("a", 0) + numberInput("b", 0)
193
+ case "mix":
194
+ return mixColours(colourInput("a", "#000000"), colourInput("b", "#ffffff"), numberInput("factor", 0.5))
195
+ case "output":
196
+ return input("value")
197
+ }
198
+ }
199
+
200
+ function formatValue(value: Value): string {
201
+ if (value === null) return "–"
202
+ if (typeof value === "number") return String(Math.round(value * 100) / 100)
203
+ return value
204
+ }
205
+
206
+ /** True when "id" is "target" or reads from it, so connecting target -> id would loop */
207
+ function dependsOn(id: string, target: string, connections: Connection[]): boolean {
208
+ if (id === target) return true
209
+ return connections.some((c) => c.to === id && dependsOn(c.from, target, connections))
210
+ }
211
+
212
+ function compatible(a: PortType, b: PortType): boolean {
213
+ return a === b || a === "any" || b === "any"
214
+ }
215
+
216
+ // ── Layout ──
217
+ // Port positions are derived from the NodeCard layout so wires can be drawn
218
+ // without measuring the DOM: header, input ports, divider, output port.
219
+
220
+ const NODE_WIDTH = 224
221
+ const BORDER = 1
222
+ const HEADER_HEIGHT = 32
223
+ const PORT_HEIGHT = 24
224
+ const PORT_GAP = 4
225
+ const PORTS_PADDING = 8
226
+
227
+ function inputPosition(node: GraphNode, index: number) {
228
+ return {
229
+ x: node.x,
230
+ y: node.y + BORDER + HEADER_HEIGHT + PORTS_PADDING + index * (PORT_HEIGHT + PORT_GAP) + PORT_HEIGHT / 2,
231
+ }
82
232
  }
83
233
 
84
- function ColourNode({ node, selected, onSelect }: { node: NodeData; selected: boolean; onSelect: () => void }) {
234
+ function outputPosition(node: GraphNode) {
235
+ const inputs = KINDS[node.kind].inputs.length
236
+ const inputsHeight =
237
+ inputs > 0 ? PORTS_PADDING * 2 + inputs * PORT_HEIGHT + (inputs - 1) * PORT_GAP + 1 : 0
238
+ return {
239
+ x: node.x + NODE_WIDTH,
240
+ y: node.y + BORDER + HEADER_HEIGHT + inputsHeight + PORTS_PADDING + PORT_HEIGHT / 2,
241
+ }
242
+ }
243
+
244
+ function wirePath(from: { x: number; y: number }, to: { x: number; y: number }): string {
245
+ const bend = Math.max(40, Math.abs(to.x - from.x) / 2)
85
246
  return (
86
- <div
87
- className="absolute"
88
- style={{ left: node.x, top: node.y }}
89
- onClick={onSelect}
90
- >
91
- <Node data-selected={selected || undefined}>
92
- <NodeHeader>
93
- <NodeTitle>{node.label}</NodeTitle>
94
- </NodeHeader>
95
- <NodeContent>
96
- <NodePort type="colour" direction="input" label="Colour A" />
97
- <NodePort type="colour" direction="input" label="Colour B" />
98
- <NodePort type="number" direction="input" label="Factor" />
99
- <NodeDivider />
100
- <NodePort type="colour" direction="output" label="Result" />
101
- </NodeContent>
102
- </Node>
103
- </div>
247
+ "M " + from.x + " " + from.y +
248
+ " C " + (from.x + bend) + " " + from.y +
249
+ ", " + (to.x - bend) + " " + to.y +
250
+ ", " + to.x + " " + to.y
104
251
  )
105
252
  }
106
253
 
254
+ // ── App ──
255
+
256
+ type Tool = "select" | "hand"
257
+
258
+ type Drag =
259
+ | { kind: "node"; id: string; startX: number; startY: number; originX: number; originY: number }
260
+ | { kind: "pan"; startX: number; startY: number; originX: number; originY: number }
261
+
262
+ const actionButton =
263
+ "inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium outline-none transition-all hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-3.5"
264
+
107
265
  export default function App() {
108
- const [tool, setTool] = useState("select")
266
+ const [tool, setTool] = useState<Tool>("select")
109
267
  const [zoom, setZoom] = useState(1)
110
- const [nodes] = useState(INITIAL_NODES)
268
+ const [pan, setPan] = useState({ x: 0, y: 0 })
269
+ const [nodes, setNodes] = useState(INITIAL_NODES)
270
+ const [connections, setConnections] = useState(INITIAL_CONNECTIONS)
111
271
  const [selectedId, setSelectedId] = useState<string | null>(null)
272
+ /** Node whose output port was clicked and is waiting for an input port */
273
+ const [pendingFrom, setPendingFrom] = useState<string | null>(null)
274
+ const drag = useRef<Drag | null>(null)
275
+ const nextId = useRef(INITIAL_NODES.length + 1)
112
276
 
113
277
  const selected = nodes.find((n) => n.id === selectedId) ?? null
278
+ const pendingType = (() => {
279
+ const node = nodes.find((n) => n.id === pendingFrom)
280
+ return node ? (KINDS[node.kind].output?.type ?? null) : null
281
+ })()
282
+
283
+ const updateNode = (id: string, patch: Partial<GraphNode>) => {
284
+ setNodes((current) => current.map((n) => (n.id === id ? { ...n, ...patch } : n)))
285
+ }
286
+
287
+ const addNode = (kind: NodeKind) => {
288
+ const id = kind + "-" + nextId.current++
289
+ const offset = (nodes.length % 6) * 24
290
+ const node: GraphNode = {
291
+ id,
292
+ kind,
293
+ x: Math.round((200 + offset - pan.x) / zoom),
294
+ y: Math.round((200 + offset - pan.y) / zoom),
295
+ value: 1,
296
+ colour: "#22c55e",
297
+ }
298
+ setNodes((current) => [...current, node])
299
+ setSelectedId(id)
300
+ }
301
+
302
+ const removeNode = (id: string) => {
303
+ setNodes((current) => current.filter((n) => n.id !== id))
304
+ setConnections((current) => current.filter((c) => c.from !== id && c.to !== id))
305
+ setSelectedId((current) => (current === id ? null : current))
306
+ setPendingFrom((current) => (current === id ? null : current))
307
+ }
308
+
309
+ const handleOutputClick = (id: string) => {
310
+ setPendingFrom((current) => (current === id ? null : id))
311
+ }
312
+
313
+ const handleInputClick = (node: GraphNode, port: PortSpec) => {
314
+ const withoutPort = connections.filter((c) => !(c.to === node.id && c.input === port.id))
315
+ if (pendingFrom === null || pendingType === null) {
316
+ // Clicking a connected input with nothing pending disconnects it
317
+ setConnections(withoutPort)
318
+ return
319
+ }
320
+ if (!compatible(pendingType, port.type)) return
321
+ if (dependsOn(pendingFrom, node.id, connections)) return
322
+ setConnections([...withoutPort, { from: pendingFrom, to: node.id, input: port.id }])
323
+ setPendingFrom(null)
324
+ }
325
+
326
+ // ── Pointer: drag nodes, pan the canvas ──
327
+
328
+ const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
329
+ if (!(e.target instanceof Element)) return
330
+ if (e.target.closest("[data-slot=node-port]")) return
331
+
332
+ const id = e.target.closest<HTMLElement>("[data-node-id]")?.dataset.nodeId
333
+ const node = nodes.find((n) => n.id === id)
334
+
335
+ if (tool === "select" && node) {
336
+ setSelectedId(node.id)
337
+ drag.current = {
338
+ kind: "node",
339
+ id: node.id,
340
+ startX: e.clientX,
341
+ startY: e.clientY,
342
+ originX: node.x,
343
+ originY: node.y,
344
+ }
345
+ return
346
+ }
347
+
348
+ if (!node) {
349
+ setSelectedId(null)
350
+ setPendingFrom(null)
351
+ }
352
+ drag.current = {
353
+ kind: "pan",
354
+ startX: e.clientX,
355
+ startY: e.clientY,
356
+ originX: pan.x,
357
+ originY: pan.y,
358
+ }
359
+ }
360
+
361
+ const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
362
+ const current = drag.current
363
+ if (!current) return
364
+ const dx = e.clientX - current.startX
365
+ const dy = e.clientY - current.startY
366
+ if (current.kind === "node") {
367
+ updateNode(current.id, {
368
+ x: Math.round(current.originX + dx / zoom),
369
+ y: Math.round(current.originY + dy / zoom),
370
+ })
371
+ } else {
372
+ setPan({ x: current.originX + dx, y: current.originY + dy })
373
+ }
374
+ }
375
+
376
+ const endDrag = () => {
377
+ drag.current = null
378
+ }
379
+
380
+ // ── Keyboard shortcuts ──
381
+
382
+ useEffect(() => {
383
+ const handleKeyDown = (e: KeyboardEvent) => {
384
+ const target = e.target
385
+ if (
386
+ target instanceof HTMLElement &&
387
+ (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
388
+ ) {
389
+ return
390
+ }
391
+ if (e.metaKey || e.ctrlKey || e.altKey) return
392
+
393
+ if (e.key === "v") setTool("select")
394
+ if (e.key === "h") setTool("hand")
395
+ if (e.key === "Escape") setPendingFrom(null)
396
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedId) {
397
+ setNodes((current) => current.filter((n) => n.id !== selectedId))
398
+ setConnections((current) =>
399
+ current.filter((c) => c.from !== selectedId && c.to !== selectedId),
400
+ )
401
+ setSelectedId(null)
402
+ setPendingFrom(null)
403
+ }
404
+ }
405
+ window.addEventListener("keydown", handleKeyDown)
406
+ return () => window.removeEventListener("keydown", handleKeyDown)
407
+ }, [selectedId])
408
+
409
+ const gridSize = 20 * zoom
114
410
 
115
411
  return (
116
412
  <div className="flex h-screen flex-col bg-background text-foreground">
117
- <div className="relative flex flex-1 overflow-hidden">
413
+ <div className="flex min-h-0 flex-1">
118
414
  {/* Node canvas */}
119
415
  <div
120
- className="relative flex-1 bg-canvas"
416
+ className={
417
+ "relative min-w-0 flex-1 touch-none select-none overflow-hidden bg-canvas " +
418
+ (tool === "hand" ? "cursor-grab" : "cursor-default")
419
+ }
121
420
  style={{
122
- backgroundImage: "radial-gradient(circle, hsl(0 0% 30%) 1px, transparent 1px)",
123
- backgroundSize: \`\${20 * zoom}px \${20 * zoom}px\`,
421
+ backgroundImage: "radial-gradient(circle, oklch(1 0 0 / 12%) 1px, transparent 1px)",
422
+ backgroundSize: gridSize + "px " + gridSize + "px",
423
+ backgroundPosition: pan.x + "px " + pan.y + "px",
124
424
  }}
125
- onClick={() => setSelectedId(null)}
425
+ onPointerDown={handlePointerDown}
426
+ onPointerMove={handlePointerMove}
427
+ onPointerUp={endDrag}
428
+ onPointerLeave={endDrag}
126
429
  >
127
- <div style={{ transform: \`scale(\${zoom})\`, transformOrigin: "0 0" }}>
128
- {nodes.map((node) =>
129
- node.type === "math" ? (
130
- <MathNode
131
- key={node.id}
132
- node={node}
133
- selected={node.id === selectedId}
134
- onSelect={() => setSelectedId(node.id)}
135
- />
136
- ) : (
137
- <ColourNode
430
+ <div
431
+ className="absolute left-0 top-0"
432
+ style={{
433
+ transform: "translate(" + pan.x + "px, " + pan.y + "px) scale(" + zoom + ")",
434
+ transformOrigin: "0 0",
435
+ }}
436
+ >
437
+ {/* Wires */}
438
+ <svg className="pointer-events-none absolute left-0 top-0 size-px overflow-visible">
439
+ {connections.map((connection) => {
440
+ const from = nodes.find((n) => n.id === connection.from)
441
+ const to = nodes.find((n) => n.id === connection.to)
442
+ if (!from || !to) return null
443
+ const output = KINDS[from.kind].output
444
+ const inputIndex = KINDS[to.kind].inputs.findIndex((p) => p.id === connection.input)
445
+ if (!output || inputIndex === -1) return null
446
+ return (
447
+ <path
448
+ key={connection.to + ":" + connection.input}
449
+ d={wirePath(outputPosition(from), inputPosition(to, inputIndex))}
450
+ fill="none"
451
+ stroke={portColourVar[output.type]}
452
+ strokeWidth={2}
453
+ />
454
+ )
455
+ })}
456
+ </svg>
457
+
458
+ {/* Nodes */}
459
+ {nodes.map((node) => {
460
+ const spec = KINDS[node.kind]
461
+ const value = evaluate(node.id, nodes, connections)
462
+ return (
463
+ <NodeCard
138
464
  key={node.id}
139
- node={node}
465
+ data-node-id={node.id}
140
466
  selected={node.id === selectedId}
141
- onSelect={() => setSelectedId(node.id)}
142
- />
467
+ className="absolute border-border bg-card data-selected:ring-ring"
468
+ style={{ left: node.x, top: node.y }}
469
+ >
470
+ <NodeHeader className="bg-muted">
471
+ <NodeTitle>{spec.title}</NodeTitle>
472
+ <NodeBadge>{spec.badge}</NodeBadge>
473
+ </NodeHeader>
474
+ {spec.inputs.length > 0 && (
475
+ <>
476
+ <NodePorts>
477
+ {spec.inputs.map((port) => {
478
+ const connected = connections.some(
479
+ (c) => c.to === node.id && c.input === port.id,
480
+ )
481
+ const accepts = pendingType !== null && compatible(pendingType, port.type)
482
+ return (
483
+ <NodePort
484
+ key={port.id}
485
+ type={port.type}
486
+ label={port.label}
487
+ direction="input"
488
+ connected={connected}
489
+ className={
490
+ "cursor-pointer pl-3 " +
491
+ (pendingType !== null && !accepts ? "opacity-40" : "")
492
+ }
493
+ onClick={() => handleInputClick(node, port)}
494
+ />
495
+ )
496
+ })}
497
+ </NodePorts>
498
+ {spec.output && <NodeDivider className="mx-0" />}
499
+ </>
500
+ )}
501
+ {spec.output && (
502
+ <NodePorts>
503
+ <NodePort
504
+ type={spec.output.type}
505
+ label={spec.output.label + " " + formatValue(value)}
506
+ direction="output"
507
+ connected={
508
+ pendingFrom === node.id || connections.some((c) => c.from === node.id)
509
+ }
510
+ className={
511
+ "cursor-pointer pr-3 " + (pendingFrom === node.id ? "text-foreground" : "")
512
+ }
513
+ onClick={() => handleOutputClick(node.id)}
514
+ />
515
+ </NodePorts>
516
+ )}
517
+ {node.kind === "output" && (
518
+ <NodePreview
519
+ className="flex items-center justify-center text-xs tabular-nums"
520
+ style={typeof value === "string" ? { backgroundColor: value } : undefined}
521
+ >
522
+ {typeof value === "string" ? null : formatValue(value)}
523
+ </NodePreview>
524
+ )}
525
+ </NodeCard>
143
526
  )
144
- )}
145
-
146
- {/* Sample SVG wire between nodes */}
147
- <svg className="pointer-events-none absolute inset-0 size-full">
148
- <path
149
- d="M 270 155 C 335 155, 335 135, 400 135"
150
- fill="none"
151
- stroke="hsl(0 0% 40%)"
152
- strokeWidth={2}
153
- />
154
- </svg>
527
+ })}
155
528
  </div>
156
529
  </div>
157
530
 
158
- <Panel position="right">
531
+ <Panel position="right" className="shrink-0">
159
532
  {selected ? (
533
+ <>
534
+ <Pane>
535
+ <PaneHeader>
536
+ <PaneLabel>{KINDS[selected.kind].title}</PaneLabel>
537
+ <span className="text-xs text-muted-foreground">{selected.id}</span>
538
+ </PaneHeader>
539
+ <PaneContent className="grid grid-cols-2 gap-2">
540
+ <NumberInput
541
+ label="X"
542
+ value={selected.x}
543
+ onChange={(x) => updateNode(selected.id, { x })}
544
+ />
545
+ <NumberInput
546
+ label="Y"
547
+ value={selected.y}
548
+ onChange={(y) => updateNode(selected.id, { y })}
549
+ />
550
+ </PaneContent>
551
+ </Pane>
552
+
553
+ {selected.kind === "number" && (
554
+ <Pane>
555
+ <PaneHeader>
556
+ <PaneLabel>Value</PaneLabel>
557
+ </PaneHeader>
558
+ <PaneContent>
559
+ <NumberInput
560
+ label="N"
561
+ value={selected.value}
562
+ step={0.05}
563
+ shiftStep={1}
564
+ onChange={(value) => updateNode(selected.id, { value })}
565
+ />
566
+ </PaneContent>
567
+ </Pane>
568
+ )}
569
+
570
+ {selected.kind === "colour" && (
571
+ <Pane>
572
+ <PaneHeader>
573
+ <PaneLabel>Colour</PaneLabel>
574
+ </PaneHeader>
575
+ <PaneContent>
576
+ <ColourPicker
577
+ value={selected.colour}
578
+ onChange={(colour) => updateNode(selected.id, { colour })}
579
+ />
580
+ </PaneContent>
581
+ </Pane>
582
+ )}
583
+
584
+ <Pane>
585
+ <PaneHeader>
586
+ <PaneLabel>Result</PaneLabel>
587
+ <span className="text-xs tabular-nums text-muted-foreground">
588
+ {formatValue(evaluate(selected.id, nodes, connections))}
589
+ </span>
590
+ </PaneHeader>
591
+ <PaneContent>
592
+ <button
593
+ type="button"
594
+ className={actionButton + " -ml-2 text-destructive"}
595
+ onClick={() => removeNode(selected.id)}
596
+ >
597
+ <RiDeleteBinLine />
598
+ Delete node
599
+ </button>
600
+ </PaneContent>
601
+ </Pane>
602
+ </>
603
+ ) : (
160
604
  <Pane>
161
605
  <PaneHeader>
162
- <PaneLabel>{selected.label}</PaneLabel>
606
+ <PaneLabel>Inspector</PaneLabel>
163
607
  </PaneHeader>
164
- <PaneContent>
165
- <div className="grid grid-cols-2 gap-2">
166
- <div>
167
- <label className="text-xs font-medium">X</label>
168
- <NumberInput value={selected.x} onChange={() => {}} />
169
- </div>
170
- <div>
171
- <label className="text-xs font-medium">Y</label>
172
- <NumberInput value={selected.y} onChange={() => {}} />
173
- </div>
174
- </div>
175
- </PaneContent>
176
- </Pane>
177
- ) : (
178
- <Pane>
179
- <PaneContent>
180
- <p className="text-sm text-muted-foreground">Click a node to inspect</p>
608
+ <PaneContent className="space-y-2 text-xs text-muted-foreground">
609
+ <p>Select a node to edit it. Drag a node to move it, drag the background to pan.</p>
610
+ <p>
611
+ Click an output port, then a matching input port, to connect them. Click a
612
+ connected input to disconnect it.
613
+ </p>
181
614
  </PaneContent>
182
615
  </Pane>
183
616
  )}
@@ -185,26 +618,38 @@ export default function App() {
185
618
  </div>
186
619
 
187
620
  <Toolbar>
188
- <ToolbarToggleGroup value={tool} onValueChange={setTool}>
189
- <ToolbarToggle value="select" tooltip="Select" shortcut="V">
621
+ <ToolbarToggleGroup
622
+ value={tool}
623
+ onValueChange={(value) => {
624
+ if (value === "select" || value === "hand") setTool(value)
625
+ }}
626
+ >
627
+ <ToolbarToggle value="select" tooltip="Select" shortcut="V" aria-label="Select">
190
628
  <RiCursorLine className="size-4" />
191
629
  </ToolbarToggle>
192
- <ToolbarToggle value="hand" tooltip="Pan" shortcut="H">
630
+ <ToolbarToggle value="hand" tooltip="Pan" shortcut="H" aria-label="Pan">
193
631
  <RiHand className="size-4" />
194
632
  </ToolbarToggle>
195
633
  </ToolbarToggleGroup>
634
+ <ToolbarSeparator />
635
+ {ADDABLE.map((kind) => (
636
+ <button key={kind} type="button" className={actionButton} onClick={() => addNode(kind)}>
637
+ <RiAddLine />
638
+ {KINDS[kind].title}
639
+ </button>
640
+ ))}
196
641
  </Toolbar>
197
642
 
198
643
  <StatusBar>
199
644
  <StatusBarSection>
200
- <StatusBarItem>Tool: {tool}</StatusBarItem>
201
- {selected && <StatusBarItem>Node: {selected.label}</StatusBarItem>}
645
+ <StatusBarItem>{nodes.length} nodes</StatusBarItem>
646
+ <StatusBarItem>{connections.length} connections</StatusBarItem>
647
+ {pendingType !== null && (
648
+ <StatusBarItem>Connecting: pick a {pendingType} input (Esc to cancel)</StatusBarItem>
649
+ )}
202
650
  </StatusBarSection>
203
- <StatusBarSection>
204
- <ZoomControls
205
- zoom={zoom}
206
- onZoomChange={setZoom}
207
- />
651
+ <StatusBarSection align="right">
652
+ <ZoomControls zoom={zoom} onZoomChange={setZoom} min={0.25} max={2} />
208
653
  </StatusBarSection>
209
654
  </StatusBar>
210
655
  </div>