From c5a2c51e9fdee420a50ff1465f16e0e27bd4c736 Mon Sep 17 00:00:00 2001 From: mitchellhansen Date: Sun, 26 Apr 2026 20:42:24 -0700 Subject: [PATCH] fix: node graph zoom, text selection, image previews, resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-frontend/src/App.jsx | 2 + src-frontend/src/components/NodeGraph.jsx | 397 +++++++++++----------- src/lib.rs | 2 +- 3 files changed, 193 insertions(+), 208 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 8898ff6c..5b056539 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -416,6 +416,8 @@ export default function App() { scheduleProcess(activePass) }} nodePreviews={passes[activePass].nodePreviews} + sourceImageB64={image?.preview_b64 ?? null} + outputImageB64={passes[activePass]?.vizB64 ?? null} /> ) : ( { - const r = canvasRef.current.getBoundingClientRect() - return { - x: (clientX - r.left - pan.x) / zoom, - y: (clientY - r.top - pan.y) / zoom, - } - }, [pan, zoom]) - - // ── Wheel zoom ───────────────────────────────────────────────────────────── + // ── Wheel zoom — zoom to cursor, computed from refs to handle rapid scroll ── const onWheel = useCallback(e => { e.preventDefault() - const r = canvasRef.current.getBoundingClientRect() - const cx = e.clientX - r.left - const cy = e.clientY - r.top + const r = canvasRef.current.getBoundingClientRect() + const cx = e.clientX - r.left + const cy = e.clientY - r.top const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1 - setZoom(z => { - const nz = Math.min(Math.max(z * factor, 0.2), 4) - setPan(p => ({ - x: cx - (cx - p.x) * (nz / z), - y: cy - (cy - p.y) * (nz / z), - })) - return nz - }) - }, []) + const z = zoomRef.current + const p = panRef.current + const nz = Math.min(Math.max(z * factor, 0.15), 5) + const np = { + x: cx - (cx - p.x) * (nz / z), + y: cy - (cy - p.y) * (nz / z), + } + // 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(() => { const el = canvasRef.current @@ -87,46 +87,39 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) { return () => el.removeEventListener('wheel', onWheel) }, [onWheel]) - // ── Canvas mouse handlers (pan + wire cancel) ────────────────────────────── - 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 ───────────────────────────────────────────────── + // ── Global mouse move / up / keydown — stable, reads from refs ───────────── useEffect(() => { function onMove(e) { - if (panRef.current) { - setPan({ x: e.clientX - panRef.current.startX, y: e.clientY - panRef.current.startY }) + if (panDragRef.current) { + const np = { x: e.clientX - panDragRef.current.startX, y: e.clientY - panDragRef.current.startY } + panRef.current = np + setPan(np) } if (nodeDragRef.current) { - const { nodeId, startNodeX, startNodeY, startX, startY } = nodeDragRef.current - const wx = startNodeX + (e.clientX - startX) / zoom - const wy = startNodeY + (e.clientY - startY) / zoom - onChange({ ...graph, nodes: graph.nodes.map(n => n.id === nodeId ? { ...n, x: wx, y: wy } : n) }) + const { nodeId, startNodeX, startNodeY, startClientX, startClientY } = nodeDragRef.current + const z = zoomRef.current + const nx = startNodeX + (e.clientX - startClientX) / z + 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) { - const r = canvasRef.current.getBoundingClientRect() - const mx = (e.clientX - r.left - pan.x) / zoom - const my = (e.clientY - r.top - pan.y) / zoom - setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w) + const r = canvasRef.current.getBoundingClientRect() + const p = panRef.current + const z = zoomRef.current + 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 } + setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w) } } - function onUp(e) { - panRef.current = null + function onUp() { + panDragRef.current = null nodeDragRef.current = null - if (e.key === undefined && wireRef.current) { - setWire(null) - wireRef.current = null - } + if (wireRef.current) { wireRef.current = null; setWire(null) } } function onKey(e) { - if (e.key === 'Escape') { - setWire(null) - wireRef.current = null - } + if (e.key === 'Escape') { wireRef.current = null; setWire(null) } } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) @@ -136,12 +129,21 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) { window.removeEventListener('mouseup', onUp) 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) { 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 state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y } wireRef.current = state @@ -152,142 +154,140 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) { e.stopPropagation() if (!wireRef.current) return const { fromId } = wireRef.current - - // Prevent self-loop and duplicate edges - if (fromId === toId) { setWire(null); wireRef.current = null; return } - const exists = graph.edges.some(ed => ed.from === fromId && ed.to === toId && ed.port === port) - if (!exists) { - // 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 }] }) + if (fromId !== toId) { + const g = graphRef.current + const filtered = g.edges.filter(ed => !(ed.to === toId && ed.port === port)) + if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) { + onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, port }] }) + } } - setWire(null) wireRef.current = null + setWire(null) } 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 ───────────────────────────────────────────────────────── 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) { - onChange({ - nodes: graph.nodes.filter(n => n.id !== id), - edges: graph.edges.filter(e => e.from !== id && e.to !== id), + const g = graphRef.current + onChangeRef.current({ + nodes: g.nodes.filter(n => n.id !== id), + edges: g.edges.filter(e => e.from !== id && e.to !== id), }) } function addNode(kind) { const r = canvasRef.current.getBoundingClientRect() - const x = (r.width / 2 - pan.x) / zoom - NODE_W / 2 - const y = (r.height / 2 - pan.y) / zoom - 60 + const p = panRef.current; const z = zoomRef.current + 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 base = { id, kind, x, y } const node = kind === 'Kernel' - ? { ...base, ...defaultKernelProps() } - : { ...base, blendMode: 'Average', inputCount: 2 } - onChange({ ...graph, nodes: [...graph.nodes, node] }) + ? { id, kind, x, y, ...defaultKernelProps() } + : { id, kind, x, y, blendMode: 'Average', inputCount: 2 } + const g = graphRef.current + onChangeRef.current({ ...g, nodes: [...g.nodes, node] }) } 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 }) } 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 if (cnt <= 2) return - const lastPort = cnt - 1 - onChange({ - ...graph, - nodes: graph.nodes.map(nd => nd.id === id ? { ...nd, inputCount: cnt - 1 } : nd), - edges: graph.edges.filter(e => !(e.to === id && e.port === lastPort)), + onChangeRef.current({ + ...g, + nodes: g.nodes.map(nd => nd.id === id ? { ...nd, inputCount: cnt - 1 } : nd), + edges: g.edges.filter(e => !(e.to === id && e.port === cnt - 1)), }) } - // ── Node header drag start ───────────────────────────────────────────────── + // ── Node header drag ─────────────────────────────────────────────────────── function startNodeDrag(e, nodeId) { - e.stopPropagation() if (e.button !== 0) return - const node = graph.nodes.find(n => n.id === nodeId) - nodeDragRef.current = { - nodeId, startNodeX: node.x, startNodeY: node.y, - startX: e.clientX, startY: e.clientY, - } + e.stopPropagation() + e.preventDefault() + const node = graphRef.current.nodes.find(n => n.id === nodeId) + nodeDragRef.current = { nodeId, startNodeX: node.x, startNodeY: node.y, startClientX: e.clientX, startClientY: e.clientY } } // ── Node rendering ───────────────────────────────────────────────────────── function renderNode(node) { const isFixed = node.kind === 'Source' || node.kind === 'Output' - const preview = nodePreviews?.[node.id] - const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2) : node.kind === 'Kernel' || node.kind === 'Output' ? 1 : 0 + const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2) + : (node.kind === 'Kernel' || node.kind === 'Output') ? 1 : 0 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 ( -
+
+ {/* Input ports */} {Array.from({ length: inputCnt }, (_, i) => (
endWire(e, node.id, i)} style={{ - position: 'absolute', - left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R, - width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', - background: wire ? '#14b8a6' : '#374151', - border: '2px solid #14b8a6', cursor: 'crosshair', zIndex: 10, - boxShadow: wire ? '0 0 6px #14b8a6' : 'none', - transition: 'background 0.15s, box-shadow 0.15s', + position: 'absolute', left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R, + width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10, + background: wire ? '#14b8a6' : '#1e3a3a', + border: `2px solid #14b8a6`, cursor: 'crosshair', + boxShadow: wire ? '0 0 8px #14b8a6aa' : 'none', + transition: 'background 0.12s, box-shadow 0.12s', }} /> ))} {/* Output port */} {hasOut && ( -
startWire(e, node.id)} +
startWire(e, node.id)} style={{ - position: 'absolute', - right: -PORT_R, top: PORT_TOP - PORT_R, - width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', - background: '#6366f1', border: '2px solid #818cf8', - cursor: 'crosshair', zIndex: 10, + position: 'absolute', right: -PORT_R, top: PORT_TOP - PORT_R, + width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10, + background: '#4f46e5', border: '2px solid #818cf8', cursor: 'crosshair', }} /> )} {/* Node body */}
- {/* Header */} -
startNodeDrag(e, node.id)} + {/* Header / drag handle */} +
startNodeDrag(e, node.id)} style={{ - padding: '4px 8px', cursor: 'move', - background: node.kind === 'Source' ? '#2e1065' : node.kind === 'Output' ? '#1c1003' : '#1e293b', - display: 'flex', alignItems: 'center', gap: 6, minHeight: 32, + padding: '4px 8px', minHeight: 32, cursor: 'move', + background: headerBg, display: 'flex', alignItems: 'center', gap: 6, }} > - - {node.kind === 'Source' ? 'Source' - : node.kind === 'Output' ? 'Output' - : node.kind === 'Kernel' ? node.kernel ?? 'Kernel' - : 'Combine'} + + {node.kind === 'Source' ? 'Source' + : node.kind === 'Output' ? 'Output' + : node.kind === 'Kernel' ? (node.kernel ?? 'Kernel') + : 'Combine'} {!isFixed && ( - )}
@@ -295,14 +295,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) { {/* Body */}
- {/* Kernel controls */} {node.kind === 'Kernel' && (<> - {/* Kernel selector */}
{KERNELS.map(k => ( - + style={{ fontSize: 12, color: '#6366f1', background: 'none', border: 'none', cursor: 'pointer', padding: '0 3px' }}>+ + style={{ fontSize: 12, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: '0 3px' }}>−
)} {/* Preview thumbnail */} {preview && ( - )}
@@ -365,72 +358,62 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) { // ── SVG edges ────────────────────────────────────────────────────────────── const nodeById = Object.fromEntries(graph.nodes.map(n => [n.id, n])) - - 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 ( - removeEdge(idx)} - /> - ) - }) - - const wireEdge = wire && ( - - ) - - // ── Canvas size for SVG ──────────────────────────────────────────────────── - const WORLD_SIZE = 4000 + const WORLD = 8000 return ( -
{/* Toolbar */} -
+
- - scroll to zoom · drag to pan · click wire to delete + + scroll=zoom · drag=pan · click wire=delete
{/* World transform */} -
- {/* SVG for edges */} - - - {svgEdges} + {/* SVG wires */} + + + {graph.edges.map((edge, idx) => { + const fn_ = nodeById[edge.from]; const tn = nodeById[edge.to] + if (!fn_ || !tn) return null + return ( + removeEdge(idx)} + /> + ) + })} - {wireEdge} + {wire && ( + + )} {/* Nodes */} diff --git a/src/lib.rs b/src/lib.rs index 3cb59989..76424f4c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { - const MAX_DIM: u32 = 256; + const MAX_DIM: u32 = 512; let gray = image::GrayImage::from_raw(w, h, map.to_vec()) .expect("bad map buffer"); let out = if w > MAX_DIM || h > MAX_DIM {