This commit is contained in:
2026-03-09 15:31:34 +01:00
parent 84e3647fb5
commit 2d6687b4ad
5 changed files with 66 additions and 21 deletions

View File

@@ -28,7 +28,6 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-zoom-pan-pinch": "^3.7.0", "react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.0.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },

View File

@@ -39,6 +39,7 @@ import {
getDefaultStyle, getDefaultStyle,
isConnectionAllowed, isConnectionAllowed,
} from './lib/nodeRegistry' } from './lib/nodeRegistry'
import type { AppNode, AppEdge } from './lib/nodeTypes'
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
@@ -46,7 +47,7 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
}) })
const initialNodes: Node[] = [ const initialNodes: AppNode[] = [
{ {
id: 'cfg_001', id: 'cfg_001',
position: { x: 50, y: 50 }, position: { x: 50, y: 50 },
@@ -63,11 +64,14 @@ const initialNodes: Node[] = [
}, },
] ]
const initialEdges: Edge[] = [ const initialEdges: AppEdge[] = [
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' }, { id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
] ]
const PROJECT_FILE_EXT = '.zui.json' const PROJECT_FILE_EXT = '.zui.json'
const PROJECT_VERSION = 1
export type ProjectMessage = { type: 'success' | 'error'; text: string }
export default function App() { export default function App() {
const { theme } = useTheme() const { theme } = useTheme()
@@ -91,6 +95,7 @@ export default function App() {
const [rfInstance, setRfInstance] = React.useState<any | null>(null) const [rfInstance, setRfInstance] = React.useState<any | null>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null) const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const [projectMessage, setProjectMessage] = React.useState<ProjectMessage | null>(null)
const wrapperRef = React.useRef<HTMLDivElement | null>(null) const wrapperRef = React.useRef<HTMLDivElement | null>(null)
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
@@ -184,6 +189,12 @@ export default function App() {
setRfInstance(instance) setRfInstance(instance)
}, []) }, [])
React.useEffect(() => {
if (!projectMessage) return
const t = setTimeout(() => setProjectMessage(null), 3000)
return () => clearTimeout(t)
}, [projectMessage])
const onNodeDragStart = useCallback(() => { const onNodeDragStart = useCallback(() => {
saveForDragEnd() saveForDragEnd()
}, [saveForDragEnd]) }, [saveForDragEnd])
@@ -193,7 +204,7 @@ export default function App() {
}, [commitDragEnd]) }, [commitDragEnd])
const handleExportProject = useCallback(() => { const handleExportProject = useCallback(() => {
const state = { nodes, edges } const state = { version: PROJECT_VERSION, nodes, edges }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
@@ -201,6 +212,7 @@ export default function App() {
a.download = `project${PROJECT_FILE_EXT}` a.download = `project${PROJECT_FILE_EXT}`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
setProjectMessage({ type: 'success', text: 'Project exported' })
}, [nodes, edges]) }, [nodes, edges])
const handleImportProject = useCallback(() => { const handleImportProject = useCallback(() => {
@@ -216,11 +228,19 @@ export default function App() {
reader.onload = () => { reader.onload = () => {
try { try {
const text = reader.result as string const text = reader.result as string
const state = JSON.parse(text) as { nodes?: Node[]; edges?: Edge[] } const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) return if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
setStateImmediate({ nodes: state.nodes, edges: state.edges }) setProjectMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' })
return
}
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
setProjectMessage(
state.version != null && state.version > PROJECT_VERSION
? { type: 'error', text: 'Project was created with a newer app version' }
: { type: 'success', text: 'Project loaded' }
)
} catch { } catch {
// Invalid JSON setProjectMessage({ type: 'error', text: 'Invalid file: not valid JSON' })
} }
} }
reader.readAsText(file) reader.readAsText(file)
@@ -383,6 +403,18 @@ export default function App() {
canUndo={canUndo} canUndo={canUndo}
canRedo={canRedo} canRedo={canRedo}
/> />
{projectMessage && (
<div
role="status"
className={`shrink-0 px-3 py-2 text-sm ${
projectMessage.type === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
}`}
>
{projectMessage.text}
</div>
)}
<div className="flex-1 min-h-0 relative flex flex-col"> <div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}> <FlowContext.Provider value={flowContextValue}>

View File

@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import type { Node, Edge } from '@xyflow/react' import type { AppNode, AppEdge } from '@/lib/nodeTypes'
export type GraphState = { nodes: Node[]; edges: Edge[] } export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
function cloneState(state: GraphState): GraphState { function cloneState(state: GraphState): GraphState {
return { return {
@@ -12,9 +12,9 @@ function cloneState(state: GraphState): GraphState {
const MAX_HISTORY = 100 const MAX_HISTORY = 100
export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edge[]) { export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
const [nodes, setNodesState] = useState<Node[]>(initialNodes) const [nodes, setNodesState] = useState<AppNode[]>(initialNodes)
const [edges, setEdgesState] = useState<Edge[]>(initialEdges) const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 }) const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
const pastRef = useRef<GraphState[]>([]) const pastRef = useRef<GraphState[]>([])
@@ -32,21 +32,21 @@ export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edg
setHistorySizes({ past: pastRef.current.length, future: 0 }) setHistorySizes({ past: pastRef.current.length, future: 0 })
}, []) }, [])
const setNodes = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => { const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current }) pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
setNodesState(typeof updater === 'function' ? updater : () => updater) setNodesState(typeof updater === 'function' ? updater : () => updater)
}, [pushToPast]) }, [pushToPast])
const setEdges = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => { const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current }) pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
setEdgesState(typeof updater === 'function' ? updater : () => updater) setEdgesState(typeof updater === 'function' ? updater : () => updater)
}, [pushToPast]) }, [pushToPast])
const setNodesSilent = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => { const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
setNodesState(typeof updater === 'function' ? updater : () => updater) setNodesState(typeof updater === 'function' ? updater : () => updater)
}, []) }, [])
const setEdgesSilent = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => { const setEdgesSilent = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
setEdgesState(typeof updater === 'function' ? updater : () => updater) setEdgesState(typeof updater === 'function' ? updater : () => updater)
}, []) }, [])

View File

@@ -1,13 +1,14 @@
import React from 'react' import React from 'react'
import type { Connection } from '@xyflow/react' import type { Connection } from '@xyflow/react'
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type FlowContextValue = { export type FlowContextValue = {
nodes: any[] nodes: AppNode[]
setNodes: (updater: any) => void setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: any[] edges: AppEdge[]
setEdges: (updater: any) => void setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
renamingNodeId: string | null renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void setRenamingNodeId: (id: string | null) => void
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */ /** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */

13
src/lib/nodeTypes.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
*/
import type { Node, Edge } from '@xyflow/react'
import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
import type { VariableNodeData } from '@/components/nodes/VariableNode'
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData
export type AppNode = Node<AppNodeData>
export type AppEdge = Edge