Compare commits
2 Commits
c55bde60e6
...
c596fc70eb
| Author | SHA1 | Date | |
|---|---|---|---|
| c596fc70eb | |||
| 40379161e0 |
42
README.md
42
README.md
@@ -1,42 +0,0 @@
|
|||||||
# ZUI
|
|
||||||
|
|
||||||
A node-based editor for configuration-driven content. Connect **Config** nodes (PlantUML, Markdown, or Wireframe) to **Render** nodes to see live output. Use **Variable** and **Function** nodes to feed data and custom logic into configs via Nunjucks templating.
|
|
||||||
|
|
||||||
## Run the app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the URL shown in the terminal (e.g. http://localhost:5173).
|
|
||||||
|
|
||||||
## Adding nodes
|
|
||||||
|
|
||||||
- **Right-click** on the canvas to open the context menu.
|
|
||||||
- Choose **Create Node** → **Config**, **Render**, **Variable**, or **Function**.
|
|
||||||
- Connect nodes by dragging from an output handle to an input handle.
|
|
||||||
|
|
||||||
## Saving and loading
|
|
||||||
|
|
||||||
- **Project → Export…** saves the current graph as a `.zui.json` file.
|
|
||||||
- **Project → Import…** loads a previously saved project.
|
|
||||||
|
|
||||||
Projects are stored as JSON with a `version` field, plus `nodes` and `edges` arrays.
|
|
||||||
|
|
||||||
## Tech
|
|
||||||
|
|
||||||
- [React Flow](https://reactflow.dev/) for the graph canvas
|
|
||||||
- [Nunjucks](https://mozilla.github.io/nunjucks/) for templating in configs
|
|
||||||
- PlantUML diagrams via [Kroki](https://kroki.io/) (proxied in dev)
|
|
||||||
- [Wireweave](https://github.com/wireweave/core) for wireframe UI → SVG
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|----------------|--------------------------|
|
|
||||||
| `npm run dev` | Start dev server |
|
|
||||||
| `npm run build`| Production build |
|
|
||||||
| `npm run preview` | Preview production build |
|
|
||||||
| `npm run test` | Run tests in watch mode |
|
|
||||||
| `npm run test:run` | Run tests once |
|
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"test": "vitest",
|
|
||||||
"test:run": "vitest run"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
@@ -34,13 +32,10 @@
|
|||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/react": "^16.0.0",
|
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"jsdom": "^25.0.0",
|
|
||||||
"vitest": "^2.0.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"shadcn": "^4.0.0",
|
"shadcn": "^4.0.0",
|
||||||
|
|||||||
77
src/App.tsx
77
src/App.tsx
@@ -33,6 +33,7 @@ import {
|
|||||||
ContextMenuGroup
|
ContextMenuGroup
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { AppMenubar } from '@/components/AppMenubar'
|
import { AppMenubar } from '@/components/AppMenubar'
|
||||||
|
import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts'
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -147,7 +148,12 @@ export default function App() {
|
|||||||
|
|
||||||
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)
|
||||||
|
const flowActionsRef = React.useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
|
||||||
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
|
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
|
||||||
|
const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
|
||||||
|
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||||
|
const nodesRef = React.useRef(nodes)
|
||||||
|
nodesRef.current = nodes
|
||||||
|
|
||||||
// Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders
|
// Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders
|
||||||
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
||||||
@@ -313,6 +319,7 @@ export default function App() {
|
|||||||
connectionFrom,
|
connectionFrom,
|
||||||
setConnectionFrom,
|
setConnectionFrom,
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
|
flowActionsRef,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
nodes,
|
nodes,
|
||||||
@@ -324,6 +331,7 @@ export default function App() {
|
|||||||
connectionFrom,
|
connectionFrom,
|
||||||
setConnectionFrom,
|
setConnectionFrom,
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
|
flowActionsRef,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -372,25 +380,40 @@ export default function App() {
|
|||||||
const position = getMenuPosition()
|
const position = getMenuPosition()
|
||||||
if (position == null) return
|
if (position == null) return
|
||||||
const nodeType = type as Node['type']
|
const nodeType = type as Node['type']
|
||||||
setNodes((nds) => {
|
const newId = getNextNodeId(nodeType, nodesRef.current.map((n) => n.id))
|
||||||
const newId = getNextNodeId(nodeType, nds.map((n) => n.id))
|
const dataMap = getDefaultDataForType(nodeType, newId)
|
||||||
const dataMap = getDefaultDataForType(nodeType, newId)
|
const style = getDefaultStyle(nodeType)
|
||||||
const style = getDefaultStyle(nodeType)
|
const newNode: Node = {
|
||||||
const newNode: Node = {
|
id: newId,
|
||||||
id: newId,
|
type: nodeType,
|
||||||
type: nodeType,
|
position: { x: position.x, y: position.y },
|
||||||
position: { x: position.x, y: position.y },
|
data: dataMap,
|
||||||
data: dataMap,
|
style,
|
||||||
style,
|
}
|
||||||
}
|
setNodes((nds) => nds.concat(newNode))
|
||||||
return nds.concat(newNode)
|
setLastCreatedNodeId(newId)
|
||||||
})
|
|
||||||
lastClickRef.current = null
|
lastClickRef.current = null
|
||||||
setContextTarget(null)
|
setContextTarget(null)
|
||||||
},
|
},
|
||||||
[getMenuPosition, setNodes]
|
[getMenuPosition, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (lastCreatedNodeId == null) return
|
||||||
|
const id = lastCreatedNodeId
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
|
||||||
|
if (nodeEl) {
|
||||||
|
setAriaAnnouncement('Node created')
|
||||||
|
nodeEl.setAttribute('tabindex', '-1')
|
||||||
|
nodeEl.focus({ preventScroll: false })
|
||||||
|
setTimeout(() => setAriaAnnouncement(null), 1000)
|
||||||
|
}
|
||||||
|
setLastCreatedNodeId(null)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [lastCreatedNodeId, nodes])
|
||||||
|
|
||||||
const pasteNode = React.useCallback(async () => {
|
const pasteNode = React.useCallback(async () => {
|
||||||
const position = getMenuPosition()
|
const position = getMenuPosition()
|
||||||
if (position == null) return
|
if (position == null) return
|
||||||
@@ -441,6 +464,14 @@ export default function App() {
|
|||||||
onContextMenu={onCanvasContextMenu}
|
onContextMenu={onCanvasContextMenu}
|
||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic
|
||||||
|
className="sr-only"
|
||||||
|
>
|
||||||
|
{ariaAnnouncement}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={importInputRef}
|
ref={importInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -456,6 +487,7 @@ export default function App() {
|
|||||||
redo={redo}
|
redo={redo}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
|
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||||
/>
|
/>
|
||||||
{projectMessage && (
|
{projectMessage && (
|
||||||
<div
|
<div
|
||||||
@@ -473,7 +505,13 @@ export default function App() {
|
|||||||
<FlowContext.Provider value={flowContextValue}>
|
<FlowContext.Provider value={flowContextValue}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="flex-1 min-h-0 w-full relative" onWheelCapture={onWheelCapture}>
|
<div
|
||||||
|
className="flex-1 min-h-0 w-full relative"
|
||||||
|
onWheelCapture={onWheelCapture}
|
||||||
|
role="application"
|
||||||
|
aria-label="Graph canvas"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
|
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
|
||||||
@@ -503,6 +541,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
<FlowFitViewOnLoad />
|
<FlowFitViewOnLoad />
|
||||||
|
<FlowKeyboardShortcuts />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -528,14 +567,18 @@ export default function App() {
|
|||||||
elementsSelectable
|
elementsSelectable
|
||||||
>
|
>
|
||||||
<Background variant="dots" gap={20} />
|
<Background variant="dots" gap={20} />
|
||||||
<Controls />
|
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
||||||
<MiniMap />
|
<Controls />
|
||||||
|
</div>
|
||||||
|
<div role="region" aria-label="Minimap: overview of the graph">
|
||||||
|
<MiniMap />
|
||||||
|
</div>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<ContextMenuContent className="w-48">
|
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
|
||||||
<ContextMenuGroup>
|
<ContextMenuGroup>
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MenubarContent,
|
MenubarContent,
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarMenu,
|
MenubarMenu,
|
||||||
|
MenubarSeparator,
|
||||||
MenubarTrigger,
|
MenubarTrigger,
|
||||||
MenubarSub,
|
MenubarSub,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
@@ -21,6 +22,7 @@ type AppMenubarProps = {
|
|||||||
redo: () => void
|
redo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
canRedo: boolean
|
canRedo: boolean
|
||||||
|
onFitView?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||||
@@ -35,7 +37,7 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }: AppMenubarProps) {
|
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo, onFitView }: AppMenubarProps) {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,6 +104,17 @@ export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }:
|
|||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-medium">View</MenubarTrigger>
|
<MenubarTrigger className="font-medium">View</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
|
{onFitView && (
|
||||||
|
<MenubarItem onClick={onFitView} className="gap-2">
|
||||||
|
Fit View
|
||||||
|
<span className="ml-auto pl-4">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>⌘0</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</span>
|
||||||
|
</MenubarItem>
|
||||||
|
)}
|
||||||
|
{onFitView && <MenubarSeparator />}
|
||||||
<MenubarSub>
|
<MenubarSub>
|
||||||
<MenubarSubTrigger>Theme</MenubarSubTrigger>
|
<MenubarSubTrigger>Theme</MenubarSubTrigger>
|
||||||
<MenubarSubContent>
|
<MenubarSubContent>
|
||||||
|
|||||||
158
src/components/FlowKeyboardShortcuts.tsx
Normal file
158
src/components/FlowKeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useCallback, useContext, useEffect } from 'react'
|
||||||
|
import { useReactFlow } from '@xyflow/react'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import FlowContext from '@/lib/flowContext'
|
||||||
|
import { getNextNodeId, getDefaultDataForType } from '@/lib/flowUtils'
|
||||||
|
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/nodeRegistry'
|
||||||
|
|
||||||
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
|
|
||||||
|
function isMod(ev: KeyboardEvent) {
|
||||||
|
return ev.ctrlKey || ev.metaKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
|
||||||
|
export function FlowKeyboardShortcuts() {
|
||||||
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||||
|
const ctx = useContext(FlowContext)
|
||||||
|
const nodes = ctx?.nodes ?? []
|
||||||
|
const setNodes = ctx?.setNodes
|
||||||
|
const setConnectionFrom = ctx?.setConnectionFrom
|
||||||
|
const flowActionsRef = ctx?.flowActionsRef
|
||||||
|
|
||||||
|
const pasteAtViewportCenter = useCallback(async () => {
|
||||||
|
if (!setNodes || !screenToFlowPosition) return
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard?.readText()
|
||||||
|
if (!text) return
|
||||||
|
const raw = JSON.parse(text) as {
|
||||||
|
id?: string
|
||||||
|
type?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
position?: { x: number; y: number }
|
||||||
|
style?: unknown
|
||||||
|
}
|
||||||
|
const validIds = getRegisteredNodeTypeIds()
|
||||||
|
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
||||||
|
const pane = document.querySelector('.react-flow__viewport')
|
||||||
|
const rect = pane?.getBoundingClientRect()
|
||||||
|
const center = rect
|
||||||
|
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||||
|
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||||
|
const position = screenToFlowPosition(center)
|
||||||
|
setNodes((nds: Node[]) => {
|
||||||
|
const newId = getNextNodeId(raw.type, nds.map((n) => n.id))
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
raw.data != null && typeof raw.data === 'object'
|
||||||
|
? { ...raw.data }
|
||||||
|
: (getDefaultDataForType(raw.type, newId) as Record<string, unknown>)
|
||||||
|
if (raw.type === 'config') data.title = `config-${newId}`
|
||||||
|
const style = getDefaultStyle(raw.type)
|
||||||
|
const newNode: Node = {
|
||||||
|
id: newId,
|
||||||
|
type: raw.type as Node['type'],
|
||||||
|
position: { x: position.x, y: position.y },
|
||||||
|
data,
|
||||||
|
style,
|
||||||
|
}
|
||||||
|
return nds.concat(newNode)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Invalid clipboard or not a copied node
|
||||||
|
}
|
||||||
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
|
const doFitView = useCallback(() => {
|
||||||
|
fitView?.({ duration: 200 })
|
||||||
|
}, [fitView])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flowActionsRef) {
|
||||||
|
flowActionsRef.current = { pasteAtViewportCenter, fitView: doFitView }
|
||||||
|
return () => {
|
||||||
|
flowActionsRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [flowActionsRef, pasteAtViewportCenter, doFitView])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
setConnectionFrom?.(null)
|
||||||
|
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
|
||||||
|
ev.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.key === 'c' && isMod(ev) && !ev.shiftKey) {
|
||||||
|
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
|
||||||
|
if (selectedNodes.length === 1) {
|
||||||
|
const node = selectedNodes[0] as Node & { selected?: boolean }
|
||||||
|
const copy = {
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
data: node.data,
|
||||||
|
position: node.position,
|
||||||
|
style: node.style,
|
||||||
|
}
|
||||||
|
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => {})
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.key === 'v' && isMod(ev) && !ev.shiftKey) {
|
||||||
|
pasteAtViewportCenter()
|
||||||
|
ev.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.key === 'd' && isMod(ev) && !ev.shiftKey) {
|
||||||
|
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
|
||||||
|
if (selectedNodes.length > 0 && setNodes) {
|
||||||
|
setNodes((nds: Node[]) => {
|
||||||
|
const existingIds = nds.map((n) => n.id)
|
||||||
|
const toAdd: Node[] = []
|
||||||
|
for (const node of selectedNodes) {
|
||||||
|
const n = node as Node & { selected?: boolean }
|
||||||
|
const pos = n.position ?? { x: 0, y: 0 }
|
||||||
|
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
|
||||||
|
existingIds.push(newId)
|
||||||
|
const newNode: Node = {
|
||||||
|
id: newId,
|
||||||
|
type: n.type,
|
||||||
|
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
||||||
|
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
|
||||||
|
style: getDefaultStyle(String(n.type)),
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newNode.data &&
|
||||||
|
typeof newNode.data === 'object' &&
|
||||||
|
'title' in newNode.data &&
|
||||||
|
String(n.type) === 'config'
|
||||||
|
) {
|
||||||
|
;(newNode.data as Record<string, unknown>).title = `${newId}`
|
||||||
|
}
|
||||||
|
toAdd.push(newNode)
|
||||||
|
}
|
||||||
|
return nds.concat(toAdd)
|
||||||
|
})
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.key === '0' && isMod(ev) && !ev.shiftKey) {
|
||||||
|
doFitView()
|
||||||
|
ev.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
}, [
|
||||||
|
nodes,
|
||||||
|
setNodes,
|
||||||
|
setConnectionFrom,
|
||||||
|
pasteAtViewportCenter,
|
||||||
|
doFitView,
|
||||||
|
])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export type BaseNodeProps = ComponentProps<"div"> & {
|
|||||||
handles?: ReactNode;
|
handles?: ReactNode;
|
||||||
/** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */
|
/** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */
|
||||||
dimensions?: { width: number; height: number };
|
dimensions?: { width: number; height: number };
|
||||||
|
/** When true, node is selected (from React Flow NodeProps). Used for visible selected state. */
|
||||||
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BaseNode({
|
export function BaseNode({
|
||||||
@@ -22,6 +24,7 @@ export function BaseNode({
|
|||||||
nodeId,
|
nodeId,
|
||||||
handles,
|
handles,
|
||||||
children,
|
children,
|
||||||
|
selected,
|
||||||
...props
|
...props
|
||||||
}: BaseNodeProps) {
|
}: BaseNodeProps) {
|
||||||
const hasSize =
|
const hasSize =
|
||||||
@@ -48,15 +51,10 @@ export function BaseNode({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground relative rounded-md border",
|
"bg-card text-card-foreground relative rounded-md border",
|
||||||
"hover:ring-1",
|
"hover:ring-1",
|
||||||
// React Flow displays node elements inside of a `NodeWrapper` component,
|
selected && "border-primary shadow-[0_0_0_2px_hsl(var(--primary)_/_0.4)]",
|
||||||
// which compiles down to a div with the class `react-flow__node`.
|
|
||||||
// When a node is selected, the class `selected` is added to the
|
|
||||||
// `react-flow__node` element. This allows us to style the node when it
|
|
||||||
// is selected, using Tailwind's `&` selector.
|
|
||||||
"[.react-flow\\_\\_node.selected_&]:border-muted-foreground",
|
|
||||||
"[.react-flow\\_\\_node.selected_&]:shadow-lg",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
data-selected={selected}
|
||||||
style={appliedStyle}
|
style={appliedStyle}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarMenu,
|
MenubarMenu,
|
||||||
MenubarSeparator,
|
MenubarSeparator,
|
||||||
|
MenubarShortcut,
|
||||||
MenubarSub,
|
MenubarSub,
|
||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
MenubarTrigger,
|
MenubarTrigger,
|
||||||
} from '../ui/menubar'
|
} from '../ui/menubar'
|
||||||
|
import { Kbd } from '../ui/kbd'
|
||||||
|
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
|
|
||||||
@@ -87,24 +89,47 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
ctx?.setRenamingNodeId?.(nodeId)
|
ctx?.setRenamingNodeId?.(nodeId)
|
||||||
}, [nodeId, ctx])
|
}, [nodeId, ctx])
|
||||||
|
|
||||||
|
const onPaste = useCallback(() => {
|
||||||
|
ctx?.flowActionsRef?.current?.pasteAtViewportCenter?.()
|
||||||
|
}, [ctx])
|
||||||
|
|
||||||
|
const onFitView = useCallback(() => {
|
||||||
|
ctx?.flowActionsRef?.current?.fitView?.()
|
||||||
|
}, [ctx])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
|
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
||||||
Node
|
Node
|
||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent className="min-w-[10rem]">
|
<MenubarContent className="min-w-[12rem]">
|
||||||
|
{/* Edit */}
|
||||||
<MenubarItem className="text-xs" onClick={onDuplicate}>
|
<MenubarItem className="text-xs" onClick={onDuplicate}>
|
||||||
Duplicate
|
Duplicate
|
||||||
|
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘D</Kbd></MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem className="text-xs" onClick={onCopy}>
|
<MenubarItem className="text-xs" onClick={onCopy}>
|
||||||
Copy
|
Copy
|
||||||
|
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘C</Kbd></MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
<MenubarItem className="text-xs" onClick={onPaste}>
|
||||||
|
Paste
|
||||||
|
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘V</Kbd></MenubarShortcut>
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarSeparator />
|
||||||
|
{/* View */}
|
||||||
|
<MenubarItem className="text-xs" onClick={onFitView}>
|
||||||
|
Fit View
|
||||||
|
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘0</Kbd></MenubarShortcut>
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarSeparator />
|
||||||
|
{/* Node */}
|
||||||
<MenubarItem className="text-xs" onClick={onRename}>
|
<MenubarItem className="text-xs" onClick={onRename}>
|
||||||
Rename
|
Rename
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem className="text-xs" onClick={onReset}>
|
<MenubarItem className="text-xs" onClick={onReset}>
|
||||||
Reset
|
Clear
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
{nodeMenuExtraContent}
|
{nodeMenuExtraContent}
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; titl
|
|||||||
|
|
||||||
type Props = AbstractNodeProps<ConfigNodeData>
|
type Props = AbstractNodeProps<ConfigNodeData>
|
||||||
|
|
||||||
function ConfigNodeComponent({ id, data, width, height }: Props) {
|
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const configTypeId = getConfigTypeId(data ?? {})
|
const configTypeId = getConfigTypeId(data ?? {})
|
||||||
const configType = getConfigType(configTypeId)
|
const configType = getConfigType(configTypeId)
|
||||||
const content = getConfigContent(data ?? {})
|
const content = getConfigContent(data ?? {})
|
||||||
@@ -232,7 +232,7 @@ function ConfigNodeComponent({ id, data, width, height }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
|
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
|
||||||
<BaseNodeHeaderRow
|
<BaseNodeHeaderRow
|
||||||
icon={<ScrollText className="size-4" />}
|
icon={<ScrollText className="size-4" />}
|
||||||
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}
|
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type FunctionNodeData = { body?: string }
|
|||||||
|
|
||||||
type Props = AbstractNodeProps<FunctionNodeData>
|
type Props = AbstractNodeProps<FunctionNodeData>
|
||||||
|
|
||||||
function FunctionNodeComponent({ id, data, width, height }: Props) {
|
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const bodyValue = data?.body ?? ''
|
const bodyValue = data?.body ?? ''
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||||
@@ -91,7 +91,7 @@ function FunctionNodeComponent({ id, data, width, height }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
|
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
|
||||||
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||||
|
|
||||||
<BaseNodeContent>
|
<BaseNodeContent>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const DEFAULT_VIEWPORT_HEIGHT = 800
|
|||||||
|
|
||||||
type Props = AbstractNodeProps<RenderingNodeData>
|
type Props = AbstractNodeProps<RenderingNodeData>
|
||||||
|
|
||||||
function RenderingNodeComponent({ id, data, width, height }: Props) {
|
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -529,6 +529,31 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
|
|||||||
img.src = dataUrl
|
img.src = dataUrl
|
||||||
}, [id, renderedContent, isSvgOutput])
|
}, [id, renderedContent, isSvgOutput])
|
||||||
|
|
||||||
|
const copyPng = useCallback(() => {
|
||||||
|
if (!renderedContent || !isSvgOutput) return
|
||||||
|
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
|
||||||
|
}, 'image/png')
|
||||||
|
}
|
||||||
|
img.onerror = () => {}
|
||||||
|
img.src = dataUrl
|
||||||
|
}, [renderedContent, isSvgOutput])
|
||||||
|
|
||||||
|
const copySvg = useCallback(() => {
|
||||||
|
if (!renderedContent || !isSvgOutput) return
|
||||||
|
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||||
|
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
|
||||||
|
}, [renderedContent, isSvgOutput])
|
||||||
|
|
||||||
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
||||||
|
|
||||||
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
|
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
|
||||||
@@ -545,7 +570,7 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
||||||
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}>
|
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
|
||||||
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||||
|
|
||||||
<BaseNodeContent>
|
<BaseNodeContent>
|
||||||
@@ -602,14 +627,20 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
|
|||||||
<MenubarSub>
|
<MenubarSub>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||||
Export
|
Export / Copy
|
||||||
</MenubarSubTrigger>
|
</MenubarSubTrigger>
|
||||||
<MenubarSubContent className="min-w-[10rem]">
|
<MenubarSubContent className="min-w-[10rem]" aria-label="Export or copy diagram">
|
||||||
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
|
||||||
PNG
|
|
||||||
</MenubarItem>
|
|
||||||
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
||||||
SVG
|
Download SVG
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
||||||
|
Download PNG
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="text-xs" onClick={copySvg} disabled={!isSvgOutput}>
|
||||||
|
Copy SVG
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="text-xs" onClick={copyPng} disabled={!isSvgOutput}>
|
||||||
|
Copy image
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</MenubarSub>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function VariableNodeComponent({ id, data }: Props) {
|
function VariableNodeComponent({ id, data, selected }: Props) {
|
||||||
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
||||||
|
|
||||||
const valueType: ValueType = data?.valueType ?? 'string'
|
const valueType: ValueType = data?.valueType ?? 'string'
|
||||||
@@ -79,7 +79,7 @@ function VariableNodeComponent({ id, data }: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="min-w-56 min-h-[180px]" handles={<OutputHandle id="out" />}>
|
<BaseNode className="min-w-56 min-h-[180px]" selected={selected} handles={<OutputHandle id="out" />}>
|
||||||
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||||
|
|
||||||
<BaseNodeContent>
|
<BaseNodeContent>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "var_001",
|
|
||||||
"type": "variable",
|
|
||||||
"position": { "x": 50, "y": 100 },
|
|
||||||
"data": { "value": "Zoe", "valueType": "string" },
|
|
||||||
"style": { "width": 224, "height": 180 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cfg_001",
|
|
||||||
"type": "config",
|
|
||||||
"position": { "x": 424, "y": 50 },
|
|
||||||
"data": {
|
|
||||||
"plantuml": "@startuml\nactor User\nparticipant \"{{ var_001 }}\" as R\nUser -> R : loves\n@enduml\n",
|
|
||||||
"title": "config-cfg_001"
|
|
||||||
},
|
|
||||||
"style": { "width": 320, "height": 320 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rnd_001",
|
|
||||||
"type": "render",
|
|
||||||
"position": { "x": 894, "y": 50 },
|
|
||||||
"data": {},
|
|
||||||
"style": { "width": 384, "height": 320 }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{ "id": "e-var_001-cfg_001", "source": "var_001", "target": "cfg_001", "type": "animated" },
|
|
||||||
{ "id": "e-cfg_001-rnd_001", "source": "cfg_001", "target": "rnd_001", "type": "animated" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { renderHook, act } from '@testing-library/react'
|
|
||||||
import { useGraphStateWithHistory } from './useGraphStateWithHistory'
|
|
||||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
|
||||||
|
|
||||||
const emptyNodes: AppNode[] = []
|
|
||||||
const emptyEdges: AppEdge[] = []
|
|
||||||
|
|
||||||
function makeNode(id: string, type: string, x: number, y: number): AppNode {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
position: { x, y },
|
|
||||||
data: {},
|
|
||||||
style: { width: 200, height: 100 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useGraphStateWithHistory', () => {
|
|
||||||
it('returns initial nodes and edges', () => {
|
|
||||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
|
||||||
const initialEdges: AppEdge[] = [{ id: 'e1', source: 'a', target: 'b' }]
|
|
||||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, initialEdges))
|
|
||||||
expect(result.current.nodes).toHaveLength(1)
|
|
||||||
expect(result.current.nodes[0].id).toBe('a')
|
|
||||||
expect(result.current.edges).toHaveLength(1)
|
|
||||||
expect(result.current.canUndo).toBe(false)
|
|
||||||
expect(result.current.canRedo).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('push to past on setNodes and allows undo', () => {
|
|
||||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
|
||||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
|
||||||
expect(result.current.canUndo).toBe(false)
|
|
||||||
act(() => {
|
|
||||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
|
||||||
})
|
|
||||||
expect(result.current.nodes).toHaveLength(2)
|
|
||||||
expect(result.current.canUndo).toBe(true)
|
|
||||||
act(() => {
|
|
||||||
result.current.undo()
|
|
||||||
})
|
|
||||||
expect(result.current.nodes).toHaveLength(1)
|
|
||||||
expect(result.current.nodes[0].id).toBe('a')
|
|
||||||
expect(result.current.canUndo).toBe(false)
|
|
||||||
expect(result.current.canRedo).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redo restores state', () => {
|
|
||||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
|
||||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
|
||||||
act(() => {
|
|
||||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
|
||||||
})
|
|
||||||
act(() => {
|
|
||||||
result.current.undo()
|
|
||||||
})
|
|
||||||
act(() => {
|
|
||||||
result.current.redo()
|
|
||||||
})
|
|
||||||
expect(result.current.nodes).toHaveLength(2)
|
|
||||||
expect(result.current.canRedo).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saveForDragEnd and commitDragEnd push drag snapshot to history', () => {
|
|
||||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
|
||||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
|
||||||
act(() => {
|
|
||||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
|
||||||
})
|
|
||||||
act(() => {
|
|
||||||
result.current.saveForDragEnd()
|
|
||||||
})
|
|
||||||
act(() => {
|
|
||||||
result.current.setNodes((prev) =>
|
|
||||||
prev.map((n) => (n.id === 'b' ? { ...n, position: { x: 200, y: 50 } } : n))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
act(() => {
|
|
||||||
result.current.commitDragEnd()
|
|
||||||
})
|
|
||||||
expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 200, y: 50 })
|
|
||||||
act(() => {
|
|
||||||
result.current.undo()
|
|
||||||
})
|
|
||||||
expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 100, y: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setStateImmediate clears history', () => {
|
|
||||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
|
||||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
|
||||||
act(() => {
|
|
||||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
|
||||||
})
|
|
||||||
expect(result.current.canUndo).toBe(true)
|
|
||||||
act(() => {
|
|
||||||
result.current.setStateImmediate({
|
|
||||||
nodes: [makeNode('c', 'variable', 0, 0)],
|
|
||||||
edges: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
expect(result.current.nodes).toHaveLength(1)
|
|
||||||
expect(result.current.nodes[0].id).toBe('c')
|
|
||||||
expect(result.current.canUndo).toBe(false)
|
|
||||||
expect(result.current.canRedo).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -4,6 +4,11 @@ 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 FlowActions = {
|
||||||
|
pasteAtViewportCenter: () => void
|
||||||
|
fitView: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export type FlowContextValue = {
|
export type FlowContextValue = {
|
||||||
nodes: AppNode[]
|
nodes: AppNode[]
|
||||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||||
@@ -15,6 +20,8 @@ export type FlowContextValue = {
|
|||||||
connectionFrom: ConnectionFrom
|
connectionFrom: ConnectionFrom
|
||||||
setConnectionFrom: (v: ConnectionFrom) => void
|
setConnectionFrom: (v: ConnectionFrom) => void
|
||||||
isValidConnection: (connection: Connection) => boolean
|
isValidConnection: (connection: Connection) => boolean
|
||||||
|
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
|
||||||
|
flowActionsRef: React.MutableRefObject<FlowActions | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlowContext = React.createContext<FlowContextValue | null>(null)
|
const FlowContext = React.createContext<FlowContextValue | null>(null)
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from 'vitest'
|
|
||||||
import { getNextNodeId, replaceNodeIdInGraph, DEFAULT_NODE_STYLE } from './flowUtils'
|
|
||||||
import { registerBuiltinNodes } from './registerBuiltinNodes'
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
registerBuiltinNodes()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getNextNodeId', () => {
|
|
||||||
it('returns prefix + 001 when no existing ids', () => {
|
|
||||||
expect(getNextNodeId('config', [])).toBe('cfg_001')
|
|
||||||
expect(getNextNodeId('render', [])).toBe('rnd_001')
|
|
||||||
expect(getNextNodeId('variable', [])).toBe('var_001')
|
|
||||||
expect(getNextNodeId('function', [])).toBe('fn_001')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('increments after existing ids', () => {
|
|
||||||
expect(getNextNodeId('config', ['cfg_001'])).toBe('cfg_002')
|
|
||||||
expect(getNextNodeId('config', ['cfg_001', 'cfg_002', 'cfg_003'])).toBe('cfg_004')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores ids of other types', () => {
|
|
||||||
expect(getNextNodeId('config', ['rnd_001', 'var_001'])).toBe('cfg_001')
|
|
||||||
expect(getNextNodeId('config', ['cfg_002'])).toBe('cfg_003')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('replaceNodeIdInGraph', () => {
|
|
||||||
it('renames node and updates edges and references in data', () => {
|
|
||||||
const nodes = [
|
|
||||||
{ id: 'cfg_001', data: { title: 'cfg_001', plantuml: 'x' }, type: 'config', position: { x: 0, y: 0 } },
|
|
||||||
{ id: 'rnd_001', data: {}, type: 'render', position: { x: 100, y: 0 } },
|
|
||||||
]
|
|
||||||
const edges = [
|
|
||||||
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001' },
|
|
||||||
]
|
|
||||||
const result = replaceNodeIdInGraph(nodes, edges, 'cfg_001', 'cfg_002')
|
|
||||||
expect(result.nodes[0].id).toBe('cfg_002')
|
|
||||||
expect(result.nodes[0].data).toEqual(expect.objectContaining({ title: 'cfg_002' }))
|
|
||||||
expect(result.edges[0].source).toBe('cfg_002')
|
|
||||||
expect(result.edges[0].id).toBe('e-cfg_002-rnd_001')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('DEFAULT_NODE_STYLE', () => {
|
|
||||||
it('has styles for config, render, variable, function', () => {
|
|
||||||
expect(DEFAULT_NODE_STYLE.config).toEqual({ width: 320, height: 320 })
|
|
||||||
expect(DEFAULT_NODE_STYLE.render).toEqual({ width: 384, height: 320 })
|
|
||||||
expect(DEFAULT_NODE_STYLE.variable).toEqual({ width: 224, height: 180 })
|
|
||||||
expect(DEFAULT_NODE_STYLE.function).toEqual({ width: 288, height: 260 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
getDefaultStyle,
|
getDefaultStyle,
|
||||||
} from './nodeRegistry'
|
} from './nodeRegistry'
|
||||||
|
|
||||||
/** Minimal node/edge shape for replaceNodeIdInGraph (avoids circular dependency on nodeTypes). */
|
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||||
type GraphNode = { id: string; data?: unknown; [k: string]: unknown }
|
|
||||||
type GraphEdge = { id: string; source: string; target: string; [k: string]: unknown }
|
|
||||||
|
|
||||||
export function nodePropsAreEqual<P extends { id?: string; data?: unknown; width?: number; height?: number; selected?: boolean }>(
|
|
||||||
prev: P,
|
prev: P,
|
||||||
next: P
|
next: P
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -60,15 +56,13 @@ function replaceInData(value: unknown, oldId: string, newId: string): unknown {
|
|||||||
|
|
||||||
/** Update graph after renaming a node: change node id and all references in data and edges */
|
/** Update graph after renaming a node: change node id and all references in data and edges */
|
||||||
export function replaceNodeIdInGraph(
|
export function replaceNodeIdInGraph(
|
||||||
nodes: GraphNode[],
|
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
|
||||||
edges: GraphEdge[],
|
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
|
||||||
oldId: string,
|
oldId: string,
|
||||||
newId: string
|
newId: string
|
||||||
): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
): { nodes: typeof nodes; edges: typeof edges } {
|
||||||
const newNodes = nodes.map((n) =>
|
const newNodes = nodes.map((n) =>
|
||||||
n.id === oldId
|
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
|
||||||
? { ...n, id: newId, data: replaceInData(n.data, oldId, newId) }
|
|
||||||
: { ...n, data: replaceInData(n.data, oldId, newId) }
|
|
||||||
)
|
)
|
||||||
const newEdges = edges.map((e) => ({
|
const newEdges = edges.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
@@ -88,12 +82,12 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||||
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
|
export function getDefaultDataForType(type: string, newId?: string): any {
|
||||||
return getDefaultDataFromRegistry(type, newId)
|
return getDefaultDataFromRegistry(type, newId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Data for Reset action. Uses nodeRegistry when type is registered. */
|
/** Data for Reset action. Uses nodeRegistry when type is registered. */
|
||||||
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
|
export function getResetDataForType(type: string, nodeId?: string): any {
|
||||||
return getResetDataFromRegistry(type, nodeId)
|
return getResetDataFromRegistry(type, nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,9 @@ export type NodeHelpEntry = {
|
|||||||
content: React.ReactNode
|
content: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props passed to registered node components: id, data, and optional dimensions/selection. */
|
|
||||||
export type NodeComponentProps = {
|
|
||||||
id: string
|
|
||||||
data?: Record<string, unknown>
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
selected?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NodeTypeDescriptor = {
|
export type NodeTypeDescriptor = {
|
||||||
id: string
|
id: string
|
||||||
component: React.ComponentType<NodeComponentProps>
|
component: React.ComponentType<any>
|
||||||
defaultStyle: { width: number; height: number }
|
defaultStyle: { width: number; height: number }
|
||||||
defaultData: Record<string, unknown>
|
defaultData: Record<string, unknown>
|
||||||
idPrefix: string
|
idPrefix: string
|
||||||
@@ -70,8 +61,8 @@ export function getDefaultDataForType(type: string, newId?: string): Record<stri
|
|||||||
const desc = registry.get(type)
|
const desc = registry.get(type)
|
||||||
if (!desc) return {}
|
if (!desc) return {}
|
||||||
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
|
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
|
||||||
const base: Record<string, unknown> = { ...desc.defaultData }
|
const base = { ...desc.defaultData }
|
||||||
if (type === 'config' && newId) base.title = `${newId}`
|
if (type === 'config' && newId) (base as any).title = `${newId}`
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from 'vitest'
|
|
||||||
import { getRegisteredNodeTypeIds } from './lib/nodeRegistry'
|
|
||||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
|
||||||
import sample from './fixtures/sample.zui.json'
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
registerBuiltinNodes()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Minimal type for parsed .zui.json project file */
|
|
||||||
type ProjectFile = {
|
|
||||||
version?: number
|
|
||||||
nodes: Array<{ id: string; type: string; data?: unknown; position?: { x: number; y: number }; style?: unknown }>
|
|
||||||
edges: Array<{ id: string; source: string; target: string; type?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('project load (.zui.json)', () => {
|
|
||||||
it('parses sample project and asserts node/edge count and types', () => {
|
|
||||||
const project = sample as ProjectFile
|
|
||||||
expect(project).toBeDefined()
|
|
||||||
expect(Array.isArray(project.nodes)).toBe(true)
|
|
||||||
expect(Array.isArray(project.edges)).toBe(true)
|
|
||||||
expect(project.nodes).toHaveLength(3)
|
|
||||||
expect(project.edges).toHaveLength(2)
|
|
||||||
const nodeTypes = project.nodes.map((n) => n.type)
|
|
||||||
expect(nodeTypes).toContain('variable')
|
|
||||||
expect(nodeTypes).toContain('config')
|
|
||||||
expect(nodeTypes).toContain('render')
|
|
||||||
const validTypeIds = getRegisteredNodeTypeIds()
|
|
||||||
for (const n of project.nodes) {
|
|
||||||
expect(validTypeIds).toContain(n.type)
|
|
||||||
}
|
|
||||||
expect(project.nodes[0].id).toBe('var_001')
|
|
||||||
expect(project.nodes[1].id).toBe('cfg_001')
|
|
||||||
expect(project.nodes[2].id).toBe('rnd_001')
|
|
||||||
expect(project.edges[0].source).toBe('var_001')
|
|
||||||
expect(project.edges[0].target).toBe('cfg_001')
|
|
||||||
expect(project.edges[1].source).toBe('cfg_001')
|
|
||||||
expect(project.edges[1].target).toBe('rnd_001')
|
|
||||||
if (project.version != null) {
|
|
||||||
expect(typeof project.version).toBe('number')
|
|
||||||
expect(project.version).toBe(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -25,6 +25,18 @@ body {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focused node: clear focus ring so keyboard/screen reader users see which node is active */
|
||||||
|
.react-flow__node:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.react-flow__node:focus-visible > div:first-child {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.4);
|
||||||
|
}
|
||||||
|
.dark .react-flow__node:focus-visible > div:first-child {
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.react-flow__node .react-flow__handle {
|
.react-flow__node .react-flow__handle {
|
||||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
resolve: {
|
|
||||||
alias: { '@': path.resolve(__dirname, './src') },
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user