fix: node graph zoom, text selection, image previews, resolution
1. Zoom-to-cursor: compute new pan/zoom synchronously from refs so rapid
wheel events are coherent (was calling setPan inside setZoom updater,
causing stale-state drift on fast scrolling)
2. Stable global handlers: move pan/zoom/graph/onChange into refs so the
mousemove/mouseup/keydown effect has no deps and never re-registers
3. Source/Output images: accept sourceImageB64 + outputImageB64 props and
show them as thumbnails on the Source and Output nodes
4. Text selection: add userSelect:none to canvas container; call
e.preventDefault() on all drag-start mousedowns; draggable={false} on
preview images
5. Preview resolution: bump map_to_b64_small max dim 256→512px so node
thumbnails stay crisp at moderate zoom levels; drop pixelated rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -416,6 +416,8 @@ export default function App() {
|
|||||||
scheduleProcess(activePass)
|
scheduleProcess(activePass)
|
||||||
}}
|
}}
|
||||||
nodePreviews={passes[activePass].nodePreviews}
|
nodePreviews={passes[activePass].nodePreviews}
|
||||||
|
sourceImageB64={image?.preview_b64 ?? null}
|
||||||
|
outputImageB64={passes[activePass]?.vizB64 ?? null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Viewport
|
<Viewport
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { KERNELS, BLEND_MODES, defaultKernelProps, newNodeId } from '../store.js
|
|||||||
// ── Layout constants ───────────────────────────────────────────────────────────
|
// ── Layout constants ───────────────────────────────────────────────────────────
|
||||||
const NODE_W = 220
|
const NODE_W = 220
|
||||||
const PORT_R = 6
|
const PORT_R = 6
|
||||||
const PORT_TOP = 18 // y offset of first port from node top (center of header)
|
const PORT_TOP = 18 // y from node top to first/only port centre
|
||||||
const PORT_STRIDE = 26 // vertical gap between combine input ports
|
const PORT_STRIDE = 26 // vertical stride between combine input ports
|
||||||
|
|
||||||
const KERNEL_PARAMS = {
|
const KERNEL_PARAMS = {
|
||||||
Luminance: ['blur_radius'],
|
Luminance: ['blur_radius'],
|
||||||
@@ -27,59 +27,59 @@ const PARAM_META = {
|
|||||||
xdog_phi: { label: 'φ', min: 1, max: 100, step: 1 },
|
xdog_phi: { label: 'φ', min: 1, max: 100, step: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Port position helpers (world coords) ───────────────────────────────────────
|
|
||||||
function outPort(node) {
|
function outPort(node) {
|
||||||
return { x: node.x + NODE_W, y: node.y + PORT_TOP }
|
return { x: node.x + NODE_W, y: node.y + PORT_TOP }
|
||||||
}
|
}
|
||||||
function inPort(node, portIdx) {
|
function inPort(node, idx) {
|
||||||
return { x: node.x, y: node.y + PORT_TOP + portIdx * PORT_STRIDE }
|
return { x: node.x, y: node.y + PORT_TOP + idx * PORT_STRIDE }
|
||||||
}
|
}
|
||||||
function edgePath(from, to) {
|
function bezier(from, to) {
|
||||||
const dx = Math.max(Math.abs(to.x - from.x) * 0.5, 60)
|
const dx = Math.max(Math.abs(to.x - from.x) * 0.5, 60)
|
||||||
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`
|
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64, outputImageB64 }) {
|
||||||
const canvasRef = useRef(null)
|
const canvasRef = useRef(null)
|
||||||
const [pan, setPan] = useState({ x: 40, y: 40 })
|
const [pan, setPan] = useState({ x: 40, y: 40 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
|
|
||||||
// pan drag
|
|
||||||
const panRef = useRef(null)
|
|
||||||
|
|
||||||
// node drag
|
|
||||||
const nodeDragRef = useRef(null)
|
|
||||||
|
|
||||||
// wire in progress
|
|
||||||
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
|
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
|
||||||
const wireRef = useRef(null)
|
|
||||||
|
|
||||||
// ── Coordinate conversion ──────────────────────────────────────────────────
|
// Refs mirror current state so event handlers don't need to be recreated
|
||||||
const toWorld = useCallback((clientX, clientY) => {
|
const panRef = useRef({ x: 40, y: 40 })
|
||||||
const r = canvasRef.current.getBoundingClientRect()
|
const zoomRef = useRef(1)
|
||||||
return {
|
const graphRef = useRef(graph)
|
||||||
x: (clientX - r.left - pan.x) / zoom,
|
const onChangeRef = useRef(onChange)
|
||||||
y: (clientY - r.top - pan.y) / zoom,
|
panRef.current = pan
|
||||||
}
|
zoomRef.current = zoom
|
||||||
}, [pan, zoom])
|
graphRef.current = graph
|
||||||
|
onChangeRef.current = onChange
|
||||||
|
|
||||||
// ── Wheel zoom ─────────────────────────────────────────────────────────────
|
// Drag state stored in refs so handlers remain stable
|
||||||
|
const panDragRef = useRef(null) // { startX, startY } — canvas-space origin
|
||||||
|
const nodeDragRef = useRef(null) // { nodeId, startNodeX/Y, startClientX/Y }
|
||||||
|
const wireRef = useRef(null) // same shape as wire state
|
||||||
|
|
||||||
|
// ── Wheel zoom — zoom to cursor, computed from refs to handle rapid scroll ──
|
||||||
const onWheel = useCallback(e => {
|
const onWheel = useCallback(e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const r = canvasRef.current.getBoundingClientRect()
|
const r = canvasRef.current.getBoundingClientRect()
|
||||||
const cx = e.clientX - r.left
|
const cx = e.clientX - r.left
|
||||||
const cy = e.clientY - r.top
|
const cy = e.clientY - r.top
|
||||||
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
|
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||||
setZoom(z => {
|
const z = zoomRef.current
|
||||||
const nz = Math.min(Math.max(z * factor, 0.2), 4)
|
const p = panRef.current
|
||||||
setPan(p => ({
|
const nz = Math.min(Math.max(z * factor, 0.15), 5)
|
||||||
|
const np = {
|
||||||
x: cx - (cx - p.x) * (nz / z),
|
x: cx - (cx - p.x) * (nz / z),
|
||||||
y: cy - (cy - p.y) * (nz / z),
|
y: cy - (cy - p.y) * (nz / z),
|
||||||
}))
|
}
|
||||||
return nz
|
// Update refs immediately so back-to-back wheel events are coherent
|
||||||
})
|
zoomRef.current = nz
|
||||||
}, [])
|
panRef.current = np
|
||||||
|
setZoom(nz)
|
||||||
|
setPan(np)
|
||||||
|
}, []) // stable — reads from refs
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = canvasRef.current
|
const el = canvasRef.current
|
||||||
@@ -87,46 +87,39 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
return () => el.removeEventListener('wheel', onWheel)
|
return () => el.removeEventListener('wheel', onWheel)
|
||||||
}, [onWheel])
|
}, [onWheel])
|
||||||
|
|
||||||
// ── Canvas mouse handlers (pan + wire cancel) ──────────────────────────────
|
// ── Global mouse move / up / keydown — stable, reads from refs ─────────────
|
||||||
function onCanvasMouseDown(e) {
|
|
||||||
if (e.target !== canvasRef.current && !e.target.dataset.canvas) return
|
|
||||||
if (e.button !== 0) return
|
|
||||||
panRef.current = { startX: e.clientX - pan.x, startY: e.clientY - pan.y }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Global mouse move / up ─────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onMove(e) {
|
function onMove(e) {
|
||||||
if (panRef.current) {
|
if (panDragRef.current) {
|
||||||
setPan({ x: e.clientX - panRef.current.startX, y: e.clientY - panRef.current.startY })
|
const np = { x: e.clientX - panDragRef.current.startX, y: e.clientY - panDragRef.current.startY }
|
||||||
|
panRef.current = np
|
||||||
|
setPan(np)
|
||||||
}
|
}
|
||||||
if (nodeDragRef.current) {
|
if (nodeDragRef.current) {
|
||||||
const { nodeId, startNodeX, startNodeY, startX, startY } = nodeDragRef.current
|
const { nodeId, startNodeX, startNodeY, startClientX, startClientY } = nodeDragRef.current
|
||||||
const wx = startNodeX + (e.clientX - startX) / zoom
|
const z = zoomRef.current
|
||||||
const wy = startNodeY + (e.clientY - startY) / zoom
|
const nx = startNodeX + (e.clientX - startClientX) / z
|
||||||
onChange({ ...graph, nodes: graph.nodes.map(n => n.id === nodeId ? { ...n, x: wx, y: wy } : n) })
|
const ny = startNodeY + (e.clientY - startClientY) / z
|
||||||
|
const g = graphRef.current
|
||||||
|
onChangeRef.current({ ...g, nodes: g.nodes.map(n => n.id === nodeId ? { ...n, x: nx, y: ny } : n) })
|
||||||
}
|
}
|
||||||
if (wireRef.current) {
|
if (wireRef.current) {
|
||||||
const r = canvasRef.current.getBoundingClientRect()
|
const r = canvasRef.current.getBoundingClientRect()
|
||||||
const mx = (e.clientX - r.left - pan.x) / zoom
|
const p = panRef.current
|
||||||
const my = (e.clientY - r.top - pan.y) / zoom
|
const z = zoomRef.current
|
||||||
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
|
const mx = (e.clientX - r.left - p.x) / z
|
||||||
|
const my = (e.clientY - r.top - p.y) / z
|
||||||
wireRef.current = { ...wireRef.current, mouseX: mx, mouseY: my }
|
wireRef.current = { ...wireRef.current, mouseX: mx, mouseY: my }
|
||||||
|
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onUp(e) {
|
function onUp() {
|
||||||
panRef.current = null
|
panDragRef.current = null
|
||||||
nodeDragRef.current = null
|
nodeDragRef.current = null
|
||||||
if (e.key === undefined && wireRef.current) {
|
if (wireRef.current) { wireRef.current = null; setWire(null) }
|
||||||
setWire(null)
|
|
||||||
wireRef.current = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') { wireRef.current = null; setWire(null) }
|
||||||
setWire(null)
|
|
||||||
wireRef.current = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.addEventListener('mousemove', onMove)
|
window.addEventListener('mousemove', onMove)
|
||||||
window.addEventListener('mouseup', onUp)
|
window.addEventListener('mouseup', onUp)
|
||||||
@@ -136,12 +129,21 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
window.removeEventListener('mouseup', onUp)
|
window.removeEventListener('mouseup', onUp)
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
}
|
}
|
||||||
}, [graph, onChange, pan, zoom])
|
}, []) // stable — all reads go through refs
|
||||||
|
|
||||||
// ── Wire interactions ──────────────────────────────────────────────────────
|
// ── Canvas background mousedown → pan ─────────────────────────────────────
|
||||||
|
function onCanvasMouseDown(e) {
|
||||||
|
if (e.target !== canvasRef.current && !e.target.dataset.canvas) return
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
panDragRef.current = { startX: e.clientX - panRef.current.x, startY: e.clientY - panRef.current.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire ───────────────────────────────────────────────────────────────────
|
||||||
function startWire(e, fromId) {
|
function startWire(e, fromId) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const node = graph.nodes.find(n => n.id === fromId)
|
e.preventDefault()
|
||||||
|
const node = graphRef.current.nodes.find(n => n.id === fromId)
|
||||||
const p = outPort(node)
|
const p = outPort(node)
|
||||||
const state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y }
|
const state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y }
|
||||||
wireRef.current = state
|
wireRef.current = state
|
||||||
@@ -152,142 +154,140 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!wireRef.current) return
|
if (!wireRef.current) return
|
||||||
const { fromId } = wireRef.current
|
const { fromId } = wireRef.current
|
||||||
|
if (fromId !== toId) {
|
||||||
// Prevent self-loop and duplicate edges
|
const g = graphRef.current
|
||||||
if (fromId === toId) { setWire(null); wireRef.current = null; return }
|
const filtered = g.edges.filter(ed => !(ed.to === toId && ed.port === port))
|
||||||
const exists = graph.edges.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)
|
if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) {
|
||||||
if (!exists) {
|
onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, port }] })
|
||||||
// Remove any existing edge to this specific port
|
}
|
||||||
const filtered = graph.edges.filter(ed => !(ed.to === toId && ed.port === port))
|
|
||||||
onChange({ ...graph, edges: [...filtered, { from: fromId, to: toId, port }] })
|
|
||||||
}
|
}
|
||||||
setWire(null)
|
|
||||||
wireRef.current = null
|
wireRef.current = null
|
||||||
|
setWire(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEdge(idx) {
|
function removeEdge(idx) {
|
||||||
onChange({ ...graph, edges: graph.edges.filter((_, i) => i !== idx) })
|
const g = graphRef.current
|
||||||
|
onChangeRef.current({ ...g, edges: g.edges.filter((_, i) => i !== idx) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Node mutations ─────────────────────────────────────────────────────────
|
// ── Node mutations ─────────────────────────────────────────────────────────
|
||||||
function updateNode(id, patch) {
|
function updateNode(id, patch) {
|
||||||
onChange({ ...graph, nodes: graph.nodes.map(n => n.id === id ? { ...n, ...patch } : n) })
|
const g = graphRef.current
|
||||||
|
onChangeRef.current({ ...g, nodes: g.nodes.map(n => n.id === id ? { ...n, ...patch } : n) })
|
||||||
}
|
}
|
||||||
function deleteNode(id) {
|
function deleteNode(id) {
|
||||||
onChange({
|
const g = graphRef.current
|
||||||
nodes: graph.nodes.filter(n => n.id !== id),
|
onChangeRef.current({
|
||||||
edges: graph.edges.filter(e => e.from !== id && e.to !== id),
|
nodes: g.nodes.filter(n => n.id !== id),
|
||||||
|
edges: g.edges.filter(e => e.from !== id && e.to !== id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function addNode(kind) {
|
function addNode(kind) {
|
||||||
const r = canvasRef.current.getBoundingClientRect()
|
const r = canvasRef.current.getBoundingClientRect()
|
||||||
const x = (r.width / 2 - pan.x) / zoom - NODE_W / 2
|
const p = panRef.current; const z = zoomRef.current
|
||||||
const y = (r.height / 2 - pan.y) / zoom - 60
|
const x = (r.width / 2 - p.x) / z - NODE_W / 2
|
||||||
|
const y = (r.height / 2 - p.y) / z - 60
|
||||||
const id = newNodeId(kind)
|
const id = newNodeId(kind)
|
||||||
const base = { id, kind, x, y }
|
|
||||||
const node = kind === 'Kernel'
|
const node = kind === 'Kernel'
|
||||||
? { ...base, ...defaultKernelProps() }
|
? { id, kind, x, y, ...defaultKernelProps() }
|
||||||
: { ...base, blendMode: 'Average', inputCount: 2 }
|
: { id, kind, x, y, blendMode: 'Average', inputCount: 2 }
|
||||||
onChange({ ...graph, nodes: [...graph.nodes, node] })
|
const g = graphRef.current
|
||||||
|
onChangeRef.current({ ...g, nodes: [...g.nodes, node] })
|
||||||
}
|
}
|
||||||
function addCombinePort(id) {
|
function addCombinePort(id) {
|
||||||
const n = graph.nodes.find(n => n.id === id)
|
const n = graphRef.current.nodes.find(n => n.id === id)
|
||||||
updateNode(id, { inputCount: (n.inputCount ?? 2) + 1 })
|
updateNode(id, { inputCount: (n.inputCount ?? 2) + 1 })
|
||||||
}
|
}
|
||||||
function removeCombinePort(id) {
|
function removeCombinePort(id) {
|
||||||
const n = graph.nodes.find(n => n.id === id)
|
const g = graphRef.current
|
||||||
|
const n = g.nodes.find(n => n.id === id)
|
||||||
const cnt = n.inputCount ?? 2
|
const cnt = n.inputCount ?? 2
|
||||||
if (cnt <= 2) return
|
if (cnt <= 2) return
|
||||||
const lastPort = cnt - 1
|
onChangeRef.current({
|
||||||
onChange({
|
...g,
|
||||||
...graph,
|
nodes: g.nodes.map(nd => nd.id === id ? { ...nd, inputCount: cnt - 1 } : nd),
|
||||||
nodes: graph.nodes.map(nd => nd.id === id ? { ...nd, inputCount: cnt - 1 } : nd),
|
edges: g.edges.filter(e => !(e.to === id && e.port === cnt - 1)),
|
||||||
edges: graph.edges.filter(e => !(e.to === id && e.port === lastPort)),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Node header drag start ─────────────────────────────────────────────────
|
// ── Node header drag ───────────────────────────────────────────────────────
|
||||||
function startNodeDrag(e, nodeId) {
|
function startNodeDrag(e, nodeId) {
|
||||||
e.stopPropagation()
|
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
const node = graph.nodes.find(n => n.id === nodeId)
|
e.stopPropagation()
|
||||||
nodeDragRef.current = {
|
e.preventDefault()
|
||||||
nodeId, startNodeX: node.x, startNodeY: node.y,
|
const node = graphRef.current.nodes.find(n => n.id === nodeId)
|
||||||
startX: e.clientX, startY: e.clientY,
|
nodeDragRef.current = { nodeId, startNodeX: node.x, startNodeY: node.y, startClientX: e.clientX, startClientY: e.clientY }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Node rendering ─────────────────────────────────────────────────────────
|
// ── Node rendering ─────────────────────────────────────────────────────────
|
||||||
function renderNode(node) {
|
function renderNode(node) {
|
||||||
const isFixed = node.kind === 'Source' || node.kind === 'Output'
|
const isFixed = node.kind === 'Source' || node.kind === 'Output'
|
||||||
const preview = nodePreviews?.[node.id]
|
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2)
|
||||||
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2) : node.kind === 'Kernel' || node.kind === 'Output' ? 1 : 0
|
: (node.kind === 'Kernel' || node.kind === 'Output') ? 1 : 0
|
||||||
const hasOut = node.kind !== 'Output'
|
const hasOut = node.kind !== 'Output'
|
||||||
|
|
||||||
|
// Preview image: explicit source/output images, or per-node detection map
|
||||||
|
const preview = node.kind === 'Source' ? sourceImageB64
|
||||||
|
: node.kind === 'Output' ? outputImageB64
|
||||||
|
: nodePreviews?.[node.id]
|
||||||
|
|
||||||
|
const accentColor = node.kind === 'Source' ? '#7c3aed'
|
||||||
|
: node.kind === 'Output' ? '#b45309'
|
||||||
|
: '#374151'
|
||||||
|
const headerBg = node.kind === 'Source' ? '#2e1065'
|
||||||
|
: node.kind === 'Output' ? '#1c1003'
|
||||||
|
: '#1e293b'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id}
|
<div key={node.id} style={{ position: 'absolute', left: node.x, top: node.y, width: NODE_W }}>
|
||||||
style={{
|
|
||||||
position: 'absolute', left: node.x, top: node.y,
|
|
||||||
width: NODE_W, userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Input ports */}
|
{/* Input ports */}
|
||||||
{Array.from({ length: inputCnt }, (_, i) => (
|
{Array.from({ length: inputCnt }, (_, i) => (
|
||||||
<div key={i}
|
<div key={i}
|
||||||
onMouseUp={e => endWire(e, node.id, i)}
|
onMouseUp={e => endWire(e, node.id, i)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute', left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R,
|
||||||
left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R,
|
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
|
||||||
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%',
|
background: wire ? '#14b8a6' : '#1e3a3a',
|
||||||
background: wire ? '#14b8a6' : '#374151',
|
border: `2px solid #14b8a6`, cursor: 'crosshair',
|
||||||
border: '2px solid #14b8a6', cursor: 'crosshair', zIndex: 10,
|
boxShadow: wire ? '0 0 8px #14b8a6aa' : 'none',
|
||||||
boxShadow: wire ? '0 0 6px #14b8a6' : 'none',
|
transition: 'background 0.12s, box-shadow 0.12s',
|
||||||
transition: 'background 0.15s, box-shadow 0.15s',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Output port */}
|
{/* Output port */}
|
||||||
{hasOut && (
|
{hasOut && (
|
||||||
<div
|
<div onMouseDown={e => startWire(e, node.id)}
|
||||||
onMouseDown={e => startWire(e, node.id)}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute', right: -PORT_R, top: PORT_TOP - PORT_R,
|
||||||
right: -PORT_R, top: PORT_TOP - PORT_R,
|
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
|
||||||
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%',
|
background: '#4f46e5', border: '2px solid #818cf8', cursor: 'crosshair',
|
||||||
background: '#6366f1', border: '2px solid #818cf8',
|
|
||||||
cursor: 'crosshair', zIndex: 10,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Node body */}
|
{/* Node body */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#1a1a2e',
|
background: '#13131f', border: `1px solid ${accentColor}`,
|
||||||
border: `1px solid ${node.kind === 'Source' ? '#7c3aed' : node.kind === 'Output' ? '#b45309' : '#374151'}`,
|
borderRadius: 8, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)',
|
||||||
borderRadius: 8, overflow: 'hidden',
|
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
{/* Header / drag handle */}
|
||||||
<div
|
<div onMouseDown={e => startNodeDrag(e, node.id)}
|
||||||
onMouseDown={e => startNodeDrag(e, node.id)}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px', cursor: 'move',
|
padding: '4px 8px', minHeight: 32, cursor: 'move',
|
||||||
background: node.kind === 'Source' ? '#2e1065' : node.kind === 'Output' ? '#1c1003' : '#1e293b',
|
background: headerBg, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
display: 'flex', alignItems: 'center', gap: 6, minHeight: 32,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em' }}>
|
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em', pointerEvents: 'none' }}>
|
||||||
{node.kind === 'Source' ? 'Source'
|
{node.kind === 'Source' ? 'Source'
|
||||||
: node.kind === 'Output' ? 'Output'
|
: node.kind === 'Output' ? 'Output'
|
||||||
: node.kind === 'Kernel' ? node.kernel ?? 'Kernel'
|
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
|
||||||
: 'Combine'}
|
: 'Combine'}
|
||||||
</span>
|
</span>
|
||||||
{!isFixed && (
|
{!isFixed && (
|
||||||
<button
|
<button onClick={() => deleteNode(node.id)} onMouseDown={e => e.stopPropagation()}
|
||||||
onClick={() => deleteNode(node.id)}
|
style={{ color: '#6b7280', fontSize: 11, cursor: 'pointer', background: 'none', border: 'none', lineHeight: 1, padding: 0 }}
|
||||||
onMouseDown={e => e.stopPropagation()}
|
|
||||||
style={{ color: '#6b7280', fontSize: 11, cursor: 'pointer', background: 'none', border: 'none', lineHeight: 1 }}
|
|
||||||
>✕</button>
|
>✕</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -295,14 +295,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
|
||||||
{/* Kernel controls */}
|
|
||||||
{node.kind === 'Kernel' && (<>
|
{node.kind === 'Kernel' && (<>
|
||||||
{/* Kernel selector */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||||
{KERNELS.map(k => (
|
{KERNELS.map(k => (
|
||||||
<button key={k}
|
<button key={k} onMouseDown={e => e.stopPropagation()} onClick={() => updateNode(node.id, { kernel: k })}
|
||||||
onMouseDown={e => e.stopPropagation()}
|
|
||||||
onClick={() => updateNode(node.id, { kernel: k })}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
|
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
|
||||||
background: node.kernel === k ? '#4f46e5' : '#1e293b',
|
background: node.kernel === k ? '#4f46e5' : '#1e293b',
|
||||||
@@ -318,22 +314,19 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
return <Slider key={p} label={m.label} value={node[p] ?? 0} min={m.min} max={m.max} step={m.step}
|
return <Slider key={p} label={m.label} value={node[p] ?? 0} min={m.min} max={m.max} step={m.step}
|
||||||
onChange={v => updateNode(node.id, { [p]: v })} />
|
onChange={v => updateNode(node.id, { [p]: v })} />
|
||||||
})}
|
})}
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 10, color: '#94a3b8' }}>
|
<label onMouseDown={e => e.stopPropagation()}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 10, color: '#94a3b8' }}>
|
||||||
<input type="checkbox" checked={node.invert ?? false}
|
<input type="checkbox" checked={node.invert ?? false}
|
||||||
onChange={e => updateNode(node.id, { invert: e.target.checked })}
|
onChange={e => updateNode(node.id, { invert: e.target.checked })}
|
||||||
style={{ accentColor: '#6366f1' }}
|
style={{ accentColor: '#6366f1' }} />
|
||||||
/>
|
|
||||||
Invert
|
Invert
|
||||||
</label>
|
</label>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{/* Combine controls */}
|
|
||||||
{node.kind === 'Combine' && (<>
|
{node.kind === 'Combine' && (<>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||||
{BLEND_MODES.map(m => (
|
{BLEND_MODES.map(m => (
|
||||||
<button key={m}
|
<button key={m} onMouseDown={e => e.stopPropagation()} onClick={() => updateNode(node.id, { blendMode: m })}
|
||||||
onMouseDown={e => e.stopPropagation()}
|
|
||||||
onClick={() => updateNode(node.id, { blendMode: m })}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
|
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
|
||||||
background: node.blendMode === m ? '#0f766e' : '#1e293b',
|
background: node.blendMode === m ? '#0f766e' : '#1e293b',
|
||||||
@@ -345,16 +338,16 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 10, color: '#6b7280' }}>Inputs: {node.inputCount ?? 2}</span>
|
<span style={{ fontSize: 10, color: '#6b7280' }}>Inputs: {node.inputCount ?? 2}</span>
|
||||||
<button onMouseDown={e => e.stopPropagation()} onClick={() => addCombinePort(node.id)}
|
<button onMouseDown={e => e.stopPropagation()} onClick={() => addCombinePort(node.id)}
|
||||||
style={{ fontSize: 11, color: '#6366f1', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px' }}>+</button>
|
style={{ fontSize: 12, color: '#6366f1', background: 'none', border: 'none', cursor: 'pointer', padding: '0 3px' }}>+</button>
|
||||||
<button onMouseDown={e => e.stopPropagation()} onClick={() => removeCombinePort(node.id)}
|
<button onMouseDown={e => e.stopPropagation()} onClick={() => removeCombinePort(node.id)}
|
||||||
style={{ fontSize: 11, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px' }}>−</button>
|
style={{ fontSize: 12, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: '0 3px' }}>−</button>
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{/* Preview thumbnail */}
|
{/* Preview thumbnail */}
|
||||||
{preview && (
|
{preview && (
|
||||||
<img src={`data:image/jpeg;base64,${preview}`} alt=""
|
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}
|
||||||
style={{ width: '100%', borderRadius: 4, marginTop: 2, display: 'block', imageRendering: 'pixelated' }}
|
style={{ width: '100%', borderRadius: 4, marginTop: 2, display: 'block' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -365,72 +358,62 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
|
|||||||
|
|
||||||
// ── SVG edges ──────────────────────────────────────────────────────────────
|
// ── SVG edges ──────────────────────────────────────────────────────────────
|
||||||
const nodeById = Object.fromEntries(graph.nodes.map(n => [n.id, n]))
|
const nodeById = Object.fromEntries(graph.nodes.map(n => [n.id, n]))
|
||||||
|
const WORLD = 8000
|
||||||
const svgEdges = graph.edges.map((edge, idx) => {
|
|
||||||
const fn_ = nodeById[edge.from]
|
|
||||||
const tn = nodeById[edge.to]
|
|
||||||
if (!fn_ || !tn) return null
|
|
||||||
const from = outPort(fn_)
|
|
||||||
const to = inPort(tn, edge.port)
|
|
||||||
return (
|
|
||||||
<path key={idx}
|
|
||||||
d={edgePath(from, to)}
|
|
||||||
stroke="#6366f1" strokeWidth={1.5} fill="none" opacity={0.7}
|
|
||||||
strokeLinecap="round"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => removeEdge(idx)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const wireEdge = wire && (
|
|
||||||
<path
|
|
||||||
d={edgePath({ x: wire.fromX, y: wire.fromY }, { x: wire.mouseX, y: wire.mouseY })}
|
|
||||||
stroke="#818cf8" strokeWidth={1.5} fill="none" strokeDasharray="6 3"
|
|
||||||
strokeLinecap="round" opacity={0.85}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Canvas size for SVG ────────────────────────────────────────────────────
|
|
||||||
const WORLD_SIZE = 4000
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', background: '#0a0a12' }}
|
<div ref={canvasRef}
|
||||||
ref={canvasRef}
|
|
||||||
onMouseDown={onCanvasMouseDown}
|
onMouseDown={onCanvasMouseDown}
|
||||||
data-canvas="1"
|
data-canvas="1"
|
||||||
|
style={{
|
||||||
|
position: 'relative', width: '100%', height: '100%',
|
||||||
|
overflow: 'hidden', background: '#0a0a14',
|
||||||
|
userSelect: 'none', WebkitUserSelect: 'none',
|
||||||
|
cursor: panDragRef.current ? 'grabbing' : 'default',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4 }}>
|
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<button onClick={() => addNode('Kernel')}
|
<button onClick={() => addNode('Kernel')}
|
||||||
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: '1px solid #374151', background: '#1e293b', color: '#94a3b8' }}
|
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: '1px solid #374151', background: '#1e293b', color: '#94a3b8' }}
|
||||||
>+ Kernel</button>
|
>+ Kernel</button>
|
||||||
<button onClick={() => addNode('Combine')}
|
<button onClick={() => addNode('Combine')}
|
||||||
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: '1px solid #374151', background: '#1e293b', color: '#94a3b8' }}
|
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: '1px solid #374151', background: '#1e293b', color: '#94a3b8' }}
|
||||||
>+ Combine</button>
|
>+ Combine</button>
|
||||||
<span style={{ fontSize: 10, color: '#374151', alignSelf: 'center', paddingLeft: 4 }}>
|
<span style={{ fontSize: 10, color: '#2d3748', paddingLeft: 4 }}>
|
||||||
scroll to zoom · drag to pan · click wire to delete
|
scroll=zoom · drag=pan · click wire=delete
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* World transform */}
|
{/* World transform */}
|
||||||
<div
|
<div data-canvas="1"
|
||||||
data-canvas="1"
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', left: 0, top: 0,
|
position: 'absolute', left: 0, top: 0, width: WORLD, height: WORLD,
|
||||||
width: WORLD_SIZE, height: WORLD_SIZE,
|
|
||||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* SVG for edges */}
|
{/* SVG wires */}
|
||||||
<svg
|
<svg style={{ position: 'absolute', top: 0, left: 0, width: WORLD, height: WORLD, overflow: 'visible', pointerEvents: 'none' }}>
|
||||||
style={{ position: 'absolute', top: 0, left: 0, width: WORLD_SIZE, height: WORLD_SIZE, overflow: 'visible', pointerEvents: 'none' }}
|
<g style={{ pointerEvents: 'stroke' }}>
|
||||||
>
|
{graph.edges.map((edge, idx) => {
|
||||||
<g style={{ pointerEvents: 'all' }}>
|
const fn_ = nodeById[edge.from]; const tn = nodeById[edge.to]
|
||||||
{svgEdges}
|
if (!fn_ || !tn) return null
|
||||||
|
return (
|
||||||
|
<path key={idx}
|
||||||
|
d={bezier(outPort(fn_), inPort(tn, edge.port))}
|
||||||
|
stroke="#6366f1" strokeWidth={1.5} fill="none" opacity={0.75}
|
||||||
|
strokeLinecap="round" style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
|
||||||
|
onClick={() => removeEdge(idx)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</g>
|
</g>
|
||||||
{wireEdge}
|
{wire && (
|
||||||
|
<path
|
||||||
|
d={bezier({ x: wire.fromX, y: wire.fromY }, { x: wire.mouseX, y: wire.mouseY })}
|
||||||
|
stroke="#818cf8" strokeWidth={1.5} fill="none" strokeDasharray="6 3" strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Nodes */}
|
{/* Nodes */}
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ fn rgba_to_b64_png(rgba: &[u8], w: u32, h: u32) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn map_to_b64_small(map: &[u8], w: u32, h: u32) -> String {
|
fn map_to_b64_small(map: &[u8], w: u32, h: u32) -> String {
|
||||||
const MAX_DIM: u32 = 256;
|
const MAX_DIM: u32 = 512;
|
||||||
let gray = image::GrayImage::from_raw(w, h, map.to_vec())
|
let gray = image::GrayImage::from_raw(w, h, map.to_vec())
|
||||||
.expect("bad map buffer");
|
.expect("bad map buffer");
|
||||||
let out = if w > MAX_DIM || h > MAX_DIM {
|
let out = if w > MAX_DIM || h > MAX_DIM {
|
||||||
|
|||||||
Reference in New Issue
Block a user