import { useContext, useEffect, useMemo, useRef } from 'preact/hooks'; import { signal, computed, batch, useSignal, useComputed, Signal } from '@preact/signals'; import { Pb } from './context.ts'; import { nodeRegistry } from './nodes'; import { SocketHandlers, SocketHandler, NodeInfo } from './node.tsx'; import { InputSocket } from './dataflow.ts'; import { Toolbar, ButtonMenu, MenuItem } from './components'; import './NodeEditor.css'; export const nodeFactory = () => { let nextNodeId = 0; return (x: number, y: number, { component, func, inputs }: NodeInfo) => { const mapEntries = (obj: {}, f: (x: [string, any]) => [string, any]) => ( Object.fromEntries(Object.entries(obj).map(f)) ); const instanceInputs = mapEntries(inputs, ([k, v]) => [k, new InputSocket(v)]); const output = computed(() => func(mapEntries(instanceInputs, ([k, v]) => [k, v.value]))); return { id: nextNodeId++, component, x: signal(x), y: signal(y), inputs: instanceInputs, outputs: mapEntries(output.value, ([k, _]) => [k, computed(() => output.value[k])]), }; }; }; interface LinkProps { fromX: Signal; fromY: Signal; toX: Signal; toY: Signal; } const Link = ({ fromX, fromY, toX, toY }: LinkProps) => { const c1x = fromX.value + Math.abs(toX.value - fromX.value) / 3; const c2x = toX.value - Math.abs(toX.value - fromX.value) / 3; return ( ); }; const getPos = (elem: Element) => { const rect = elem.getBoundingClientRect(); const x = rect.x + (rect.right - rect.x) / 2; const y = rect.y + (rect.bottom - rect.y) / 2; return {x, y}; }; interface LinkData extends LinkProps { from: { nodeId: number, socket: string }; to: { nodeId: number, socket: string }; } const NodeEditor = ({ user, project }) => { const pb = useContext(Pb); const offsetX = useSignal(0); const offsetY = useSignal(0); const scale = useSignal(1); const instantiateNode = useMemo(nodeFactory, []); const svgRef = useRef(null); const nodes = useSignal([]); const currentLink = useSignal>(null); const links = useSignal([]); const allLinks = useComputed(() => (links.value as LinkProps[]).concat(currentLink.value as LinkProps ?? [])); useEffect(async () => { const projectData = await pb.collection('projects') .getFirstListItem(pb.filter('name = {:project} && owner.username = {:user}', { project, user })); const filter = pb.filter('project.id = {:id}', { id: projectData.id }); const projectNodes = await pb.collection('nodes').getFullList({ filter }); const projectLinks = await pb.collection('links').getFullList({ filter }); console.log(projectNodes); console.log(projectLinks); const instances = projectNodes.map(node => instantiateNode(node.x, node.y, node.name)); nodes.value = nodes.value.concat(instances); }, []); const onOutMouseDown: SocketHandler = (nodeId, socket, event) => { event.stopPropagation(); const svgRect = svgRef.current?.getBoundingClientRect(); const svgX = svgRect?.x ?? 0; const svgY = svgRect?.y ?? 0; const pos = getPos(event.target as Element); pos.x -= svgX; pos.y -= svgY; const node = nodes.value.find(x => x.id === nodeId); if (!node) throw new Error('no node for mousedown id'); const xOffs = (pos.x - offsetX.value) / scale.value - node.x.value; const yOffs = (pos.y - offsetY.value) / scale.value - node.y.value; const fromX = computed(() => node.x.value + xOffs); const fromY = computed(() => node.y.value + yOffs); const mouseX = signal(event.clientX); const mouseY = signal(event.clientY); const toX = computed(() => (mouseX.value - svgX - offsetX.value) / scale.value); const toY = computed(() => (mouseY.value - svgY - offsetY.value) / scale.value); const onMouseMove = (event: MouseEvent) => batch(() => { mouseX.value += event.movementX; mouseY.value += event.movementY; }); const onMouseUp = () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); currentLink.value = null; }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); currentLink.value = {from: {nodeId, socket}, fromX, fromY, toX, toY}; }; const onInMouseDown: SocketHandler = (nodeId, socket, event) => { event.stopPropagation(); const i = links.value.findIndex(l => l.to.nodeId === nodeId && l.to.socket === socket); if (i == -1) return; const node = nodes.value.find(x => x.id === nodeId); if (!node) throw new Error('no node for inmousedown id'); const svgRect = svgRef.current?.getBoundingClientRect(); const svgX = svgRect?.x ?? 0; const svgY = svgRect?.y ?? 0; const mouseX = signal(event.clientX); const mouseY = signal(event.clientY); const toX = computed(() => (mouseX.value - svgX - offsetX.value) / scale.value); const toY = computed(() => (mouseY.value - svgY - offsetY.value) / scale.value); batch(() => { node.inputs[socket].link.value = null; currentLink.value = {...links.value[i], toX, toY}; links.value = links.value.toSpliced(i, 1); }); const onMouseMove = (event: MouseEvent) => batch(() => { mouseX.value += event.movementX; mouseY.value += event.movementY; }); const onMouseUp = () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); currentLink.value = null; }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); }; const onInMouseUp: SocketHandler = (nodeId, socket, event) => { if (!currentLink.value) return; event.stopPropagation(); const fromNode = nodes.value.find(x => x.id === currentLink.value!.from.nodeId); const node = nodes.value.find(x => x.id === nodeId); if (!node || !fromNode) throw new Error('no nodes for inmouseup ids'); const svgRect = svgRef.current?.getBoundingClientRect(); const svgX = svgRect?.x ?? 0; const svgY = svgRect?.y ?? 0; const pos = getPos(event.target as Element); pos.x -= svgX; pos.y -= svgY; const xOffs = (pos.x - offsetX.value) / scale.value - node.x.value; const yOffs = (pos.y - offsetY.value) / scale.value - node.y.value; const toX = computed(() => node.x.value + xOffs); const toY = computed(() => node.y.value + yOffs); batch(() => { node.inputs[socket].link.value = fromNode.outputs[currentLink.value!.from.socket]; links.value = [ ...links.value.filter(l => l.to.nodeId !== nodeId || l.to.socket !== socket), {...currentLink.value!, to: {nodeId, socket}, toX, toY}, ]; currentLink.value = null; }); }; const socketHandlers = { onOutMouseDown, onInMouseDown, onInMouseUp, }; const onKeyDown = (event: KeyboardEvent) => { if (event.code === 'KeyX') { alert('X'); } }; useEffect(() => { document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); const onBgMouseDown = () => { const onMouseMove = (event: MouseEvent) => batch(() => { offsetX.value += event.movementX; offsetY.value += event.movementY; }); const onMouseUp = () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); }; const onBgWheel = (event: WheelEvent) => batch(() => { const delta = event.deltaY * 0.001; offsetX.value -= (event.clientX - offsetX.value) * delta; offsetY.value -= (event.clientY - offsetY.value) * delta; scale.value *= 1 + delta; }); const addNode = (node: NodeInfo) => { nodes.value = nodes.value.concat(instantiateNode(100, 100, node)); }; return (
{Object.entries(nodeRegistry).map(([name, node]) => ( addNode(node)} /> ))} {allLinks.value.map(({fromX, fromY, toX, toY}) => ( ))} {nodes.value.map(node => ( ))}
); }; export default NodeEditor;