diff --git a/package.json b/package.json index e793b7f..45299b3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-zoom-pan-pinch": "^3.7.0", - "reactflow": "^11.0.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7" }, diff --git a/src/App.tsx b/src/App.tsx index b8a9b1e..1e80c66 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ import { getDefaultStyle, isConnectionAllowed, } from './lib/nodeRegistry' +import type { AppNode, AppEdge } from './lib/nodeTypes' const SNAP_GRID: [number, number] = [15, 15] 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], }) -const initialNodes: Node[] = [ +const initialNodes: AppNode[] = [ { id: 'cfg_001', 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' }, ] const PROJECT_FILE_EXT = '.zui.json' +const PROJECT_VERSION = 1 + +export type ProjectMessage = { type: 'success' | 'error'; text: string } export default function App() { const { theme } = useTheme() @@ -91,6 +95,7 @@ export default function App() { const [rfInstance, setRfInstance] = React.useState(null) const [renamingNodeId, setRenamingNodeId] = React.useState(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) + const [projectMessage, setProjectMessage] = React.useState(null) const wrapperRef = React.useRef(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) @@ -184,6 +189,12 @@ export default function App() { setRfInstance(instance) }, []) + React.useEffect(() => { + if (!projectMessage) return + const t = setTimeout(() => setProjectMessage(null), 3000) + return () => clearTimeout(t) + }, [projectMessage]) + const onNodeDragStart = useCallback(() => { saveForDragEnd() }, [saveForDragEnd]) @@ -193,7 +204,7 @@ export default function App() { }, [commitDragEnd]) 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 url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -201,6 +212,7 @@ export default function App() { a.download = `project${PROJECT_FILE_EXT}` a.click() URL.revokeObjectURL(url) + setProjectMessage({ type: 'success', text: 'Project exported' }) }, [nodes, edges]) const handleImportProject = useCallback(() => { @@ -216,11 +228,19 @@ export default function App() { reader.onload = () => { try { const text = reader.result as string - const state = JSON.parse(text) as { nodes?: Node[]; edges?: Edge[] } - if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) return - setStateImmediate({ nodes: state.nodes, edges: state.edges }) + const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] } + if (!state || !Array.isArray(state.nodes) || !Array.isArray(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 { - // Invalid JSON + setProjectMessage({ type: 'error', text: 'Invalid file: not valid JSON' }) } } reader.readAsText(file) @@ -383,6 +403,18 @@ export default function App() { canUndo={canUndo} canRedo={canRedo} /> + {projectMessage && ( +
+ {projectMessage.text} +
+ )}
diff --git a/src/hooks/useGraphStateWithHistory.ts b/src/hooks/useGraphStateWithHistory.ts index 8276f79..2f4f9fb 100644 --- a/src/hooks/useGraphStateWithHistory.ts +++ b/src/hooks/useGraphStateWithHistory.ts @@ -1,7 +1,7 @@ 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 { return { @@ -12,9 +12,9 @@ function cloneState(state: GraphState): GraphState { const MAX_HISTORY = 100 -export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edge[]) { - const [nodes, setNodesState] = useState(initialNodes) - const [edges, setEdgesState] = useState(initialEdges) +export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) { + const [nodes, setNodesState] = useState(initialNodes) + const [edges, setEdgesState] = useState(initialEdges) const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 }) const pastRef = useRef([]) @@ -32,21 +32,21 @@ export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edg 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 }) setNodesState(typeof updater === 'function' ? updater : () => updater) }, [pushToPast]) - const setEdges = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => { + const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => { pushToPast({ nodes: nodesRef.current, edges: edgesRef.current }) setEdgesState(typeof updater === 'function' ? updater : () => updater) }, [pushToPast]) - const setNodesSilent = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => { + const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => { 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) }, []) diff --git a/src/lib/flowContext.tsx b/src/lib/flowContext.tsx index 9b5b0ee..4eaaf15 100644 --- a/src/lib/flowContext.tsx +++ b/src/lib/flowContext.tsx @@ -1,13 +1,14 @@ import React from 'react' import type { Connection } from '@xyflow/react' +import type { AppNode, AppEdge } from '@/lib/nodeTypes' export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null export type FlowContextValue = { - nodes: any[] - setNodes: (updater: any) => void - edges: any[] - setEdges: (updater: any) => void + nodes: AppNode[] + setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void + edges: AppEdge[] + setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void renamingNodeId: string | null setRenamingNodeId: (id: string | null) => void /** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */ diff --git a/src/lib/nodeTypes.ts b/src/lib/nodeTypes.ts new file mode 100644 index 0000000..942c370 --- /dev/null +++ b/src/lib/nodeTypes.ts @@ -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 +export type AppEdge = Edge