create-substrate 0.1.4 → 0.1.5
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.
- package/dist/index.js +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +57 -2
- package/dist/scaffold.js.map +1 -1
- package/dist/surfaces/3d-scene.d.ts.map +1 -1
- package/dist/surfaces/3d-scene.js +414 -67
- package/dist/surfaces/3d-scene.js.map +1 -1
- package/dist/surfaces/animation.d.ts.map +1 -1
- package/dist/surfaces/animation.js +95 -103
- package/dist/surfaces/animation.js.map +1 -1
- package/dist/surfaces/blank.d.ts.map +1 -1
- package/dist/surfaces/blank.js +28 -16
- package/dist/surfaces/blank.js.map +1 -1
- package/dist/surfaces/canvas-2d.d.ts.map +1 -1
- package/dist/surfaces/canvas-2d.js +369 -92
- package/dist/surfaces/canvas-2d.js.map +1 -1
- package/dist/surfaces/data-vis.d.ts.map +1 -1
- package/dist/surfaces/data-vis.js +262 -98
- package/dist/surfaces/data-vis.js.map +1 -1
- package/dist/surfaces/image-gen.d.ts.map +1 -1
- package/dist/surfaces/image-gen.js +354 -92
- package/dist/surfaces/image-gen.js.map +1 -1
- package/dist/surfaces/node-editor.d.ts.map +1 -1
- package/dist/surfaces/node-editor.js +559 -114
- package/dist/surfaces/node-editor.js.map +1 -1
- package/package.json +3 -2
- package/skills/canvas-2d/SKILL.md +1 -1
- package/skills/composing-panels/SKILL.md +8 -8
- package/skills/scaffold-playground/SKILL.md +4 -4
|
@@ -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 {
|
|
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
|
-
|
|
35
|
+
NodeCard,
|
|
37
36
|
NodeHeader,
|
|
38
37
|
NodeTitle,
|
|
39
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 [
|
|
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="
|
|
413
|
+
<div className="flex min-h-0 flex-1">
|
|
118
414
|
{/* Node canvas */}
|
|
119
415
|
<div
|
|
120
|
-
className=
|
|
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,
|
|
123
|
-
backgroundSize:
|
|
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
|
-
|
|
425
|
+
onPointerDown={handlePointerDown}
|
|
426
|
+
onPointerMove={handlePointerMove}
|
|
427
|
+
onPointerUp={endDrag}
|
|
428
|
+
onPointerLeave={endDrag}
|
|
126
429
|
>
|
|
127
|
-
<div
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
|
|
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
|
-
|
|
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>
|
|
606
|
+
<PaneLabel>Inspector</PaneLabel>
|
|
163
607
|
</PaneHeader>
|
|
164
|
-
<PaneContent>
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
189
|
-
|
|
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>
|
|
201
|
-
|
|
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>
|