From 3b8c7e391e1f370af74df30a14a699a49543f918 Mon Sep 17 00:00:00 2001 From: Sam Nystrom Date: Sat, 16 Mar 2024 21:09:02 +0000 Subject: refactor: extract NodeEditor into a component --- src/NodeEditor.css | 18 --- src/NodeEditor.tsx | 286 ------------------------------------------ src/components/NodeEditor.css | 18 +++ src/components/NodeEditor.tsx | 286 ++++++++++++++++++++++++++++++++++++++++++ src/components/index.ts | 4 +- src/index.tsx | 5 +- src/pages/Editor.tsx | 30 +++++ src/pages/index.ts | 5 +- 8 files changed, 342 insertions(+), 310 deletions(-) delete mode 100644 src/NodeEditor.css delete mode 100644 src/NodeEditor.tsx create mode 100644 src/components/NodeEditor.css create mode 100644 src/components/NodeEditor.tsx create mode 100644 src/pages/Editor.tsx diff --git a/src/NodeEditor.css b/src/NodeEditor.css deleted file mode 100644 index 5a8a683..0000000 --- a/src/NodeEditor.css +++ /dev/null @@ -1,18 +0,0 @@ -@scope (.__NodeEditor) { - :scope { - --base: #121212; - --surface: #242424; - --overlay: #484848; - --primary: #1c4c87; - - --data-any: #36c965; - --data-float: #a0a0a0; - --data-vector: #5e29d9; - } - - .link { - fill: none; - stroke: var(--data-float); - stroke-width: 2; - } -} \ No newline at end of file diff --git a/src/NodeEditor.tsx b/src/NodeEditor.tsx deleted file mode 100644 index 781e64b..0000000 --- a/src/NodeEditor.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { useContext, useEffect, useMemo, useCallback, useRef } from 'preact/hooks'; -import { signal, computed, batch, useSignal, useComputed, Signal } from '@preact/signals'; -import { Pb } from './context.ts'; -import { NodeComponent, SocketHandlers } from './node.ts'; -import { nodeRegistry } from './nodes'; -import type { SocketHandler, NodeInfo } from './node.ts'; -import { InputSocket } from './dataflow.ts'; -import { Toolbar, ButtonMenu, MenuItem } from './components'; -import './NodeEditor.css'; - -interface NodeInstance { - id: number; - component: NodeComponent; - x: Signal; - y: Signal; - inputs: Record>; - outputs: Record>; -} - -const nodeFactory = () => { - let nextNodeId = 0; - return (x: number, y: number, { component, func, inputs }: NodeInfo): NodeInstance => { - 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 }; -} - -export interface NodeEditorProps { - user: string; - project: string; -} - -const NodeEditor = ({ user, project }: NodeEditorProps) => { - 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 }); - const instances = projectNodes.map(node => instantiateNode(node.x, node.y, node.name)); - nodes.value = nodes.value.concat(instances); - }, []); - - const onOutMouseDown: SocketHandler = useCallback((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 = useCallback((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 = useCallback((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 = useCallback((event: KeyboardEvent) => { - if (event.code === 'KeyX') { - alert('X'); - } - }, []); - - useEffect(() => { - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, []); - - const onBgMouseDown = useCallback(() => { - 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 = useCallback((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 = useCallback((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; \ No newline at end of file diff --git a/src/components/NodeEditor.css b/src/components/NodeEditor.css new file mode 100644 index 0000000..5a8a683 --- /dev/null +++ b/src/components/NodeEditor.css @@ -0,0 +1,18 @@ +@scope (.__NodeEditor) { + :scope { + --base: #121212; + --surface: #242424; + --overlay: #484848; + --primary: #1c4c87; + + --data-any: #36c965; + --data-float: #a0a0a0; + --data-vector: #5e29d9; + } + + .link { + fill: none; + stroke: var(--data-float); + stroke-width: 2; + } +} \ No newline at end of file diff --git a/src/components/NodeEditor.tsx b/src/components/NodeEditor.tsx new file mode 100644 index 0000000..758fb76 --- /dev/null +++ b/src/components/NodeEditor.tsx @@ -0,0 +1,286 @@ +import { useContext, useEffect, useMemo, useCallback, useRef } from 'preact/hooks'; +import { signal, computed, batch, useSignal, useComputed, Signal } from '@preact/signals'; +import { Pb } from '../context.ts'; +import type { Project } from '../types.ts'; +import { NodeComponent, SocketHandlers } from '../node.ts'; +import { nodeRegistry } from '../nodes'; +import type { SocketHandler, NodeInfo } from '../node.ts'; +import { InputSocket } from '../dataflow.ts'; +import Toolbar from './Toolbar.tsx'; +import ButtonMenu from './ButtonMenu.tsx'; +import MenuItem from './MenuItem.tsx'; +import './NodeEditor.css'; + +interface NodeInstance { + id: number; + component: NodeComponent; + x: Signal; + y: Signal; + inputs: Record>; + outputs: Record>; +} + +const instantiateNode = (id: string, x: number, y: number, { component, func, inputs }: NodeInfo): NodeInstance => { + 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, + 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 }; +} + +export interface NodeEditorProps { + project: Project; +} + +const NodeEditor = ({ project }: NodeEditorProps) => { + const pb = useContext(Pb)!; + + const offsetX = useSignal(0); + const offsetY = useSignal(0); + const scale = useSignal(1); + + 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 filter = pb.filter('project.id = {:id}', { id: project.id }); + const projectNodes = await pb.collection('nodes').getFullList({ filter }); + const projectLinks = await pb.collection('links').getFullList({ filter }); + const instances = projectNodes.map(node => instantiateNode(node.id, node.x, node.y, node.name)); + nodes.value = nodes.value.concat(instances); + })(); + }, []); + + const onOutMouseDown: SocketHandler = useCallback((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 = useCallback((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 = useCallback((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 = useMemo(() => ({ + onOutMouseDown, + onInMouseDown, + onInMouseUp, + }), []); + + const onKeyDown = useCallback((event: KeyboardEvent) => { + if (event.code === 'KeyX') { + alert('X'); + } + }, []); + + useEffect(() => { + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + + const onBgMouseDown = useCallback(() => { + 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 = useCallback((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 = useCallback(async (name: string, info: NodeInfo) => { + const node = await pb.collection('nodes').create({ x: 100, y: 100, type: name, project: projectId, collapsed: false }); + alert(JSON.stringify(node)); + nodes.value = nodes.value.concat(instantiateNode(node.id, node.x, node.y, info)); + }, []); + + return ( +
+ + + {Object.entries(nodeRegistry).map(([name, node]) => ( + addNode(name, node)} /> + ))} + + + + + + + + + + + + + + + {allLinks.value.map(({fromX, fromY, toX, toY}) => ( + + ))} + + {nodes.value.map(node => ( + + ))} + + + +
+ ); +}; + +export default NodeEditor; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index badd32e..21b1267 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,6 +8,7 @@ export { default as FormLabel } from './FormLabel.tsx'; export { default as Header } from './Header.tsx'; export { default as Menu } from './Menu.tsx'; export { default as MenuItem } from './MenuItem.tsx'; +export { default as NodeEditor } from './NodeEditor.tsx'; export { default as TextInput } from './TextInput.tsx'; export { default as Toolbar } from './Toolbar.tsx'; @@ -19,7 +20,8 @@ export type { ContentProps } from './Content.tsx'; export type { FormLabelProps } from './FormLabel.tsx'; export type { FormProps } from './Form.tsx'; export type { HeaderProps } from './Header.tsx'; -export type { MenuItemProps } from './MenuItem.tsx'; export type { MenuProps } from './Menu.tsx'; +export type { MenuItemProps } from './MenuItem.tsx'; +export type { NodeEditorProps } from './NodeEditor.tsx'; export type { TextInputProps } from './TextInput.tsx'; export type { ToolbarProps } from './Toolbar.tsx'; diff --git a/src/index.tsx b/src/index.tsx index 6fdf07e..cd8c870 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,8 +3,7 @@ import { useMemo } from 'preact/hooks'; import { Router } from 'preact-router'; import PocketBase from 'pocketbase'; import { Pb } from './context.ts'; -import { Home, SignUp, LogIn, ProjectsList } from './pages'; -import NodeEditor from './NodeEditor.tsx'; +import { Home, SignUp, LogIn, ProjectsList, Editor } from './pages'; import './index.css'; export const App = () => { @@ -16,7 +15,7 @@ export const App = () => { - + ); diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx new file mode 100644 index 0000000..c929595 --- /dev/null +++ b/src/pages/Editor.tsx @@ -0,0 +1,30 @@ +import { useEffect, useMemo, useContext } from 'preact/hooks'; +import { useSignal } from '@preact/signals'; +import { Pb } from '../context.ts'; +import type { Project } from '../types.ts'; +import { NodeEditor } from '../components'; + +export interface EditorProps { + user: string; + project: string; +} + +const Editor = ({ user, project }: EditorProps) => { + const pb = useContext(Pb)!; + const projectData = useSignal(null); + + useEffect(() => { + (async () => { + projectData.value = await pb.collection('projects') + .getFirstListItem(pb.filter('owner.username = {:user} && name = {:project}', { user, project })); + })(); + }, []); + + return ( + <> + {!!projectData.value && } + + ); +}; + +export default Editor; diff --git a/src/pages/index.ts b/src/pages/index.ts index bd162cf..27e3906 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,4 +1,5 @@ +export { default as Editor } from './Editor.tsx'; export { default as Home } from './Home.tsx'; -export { default as SignUp } from './SignUp.tsx'; export { default as LogIn } from './LogIn.tsx'; -export { default as ProjectsList } from './ProjectsList.tsx'; \ No newline at end of file +export { default as ProjectsList } from './ProjectsList.tsx'; +export { default as SignUp } from './SignUp.tsx'; -- cgit v1.2.3