improv 1
This commit is contained in:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
46
src/App.tsx
46
src/App.tsx
@@ -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}>
|
||||||
|
|||||||
@@ -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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -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
13
src/lib/nodeTypes.ts
Normal 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
|
||||||
Reference in New Issue
Block a user