This commit is contained in:
2026-03-09 16:08:56 +01:00
parent c55bde60e6
commit 40379161e0
17 changed files with 128 additions and 360 deletions

View File

@@ -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 |

View File

@@ -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",

View File

@@ -148,6 +148,10 @@ 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 [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>[]>([])
@@ -372,25 +376,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 +460,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"
@@ -473,7 +500,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">
@@ -528,14 +561,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>

View File

@@ -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}

View File

@@ -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}`} />}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" }
]
}

View File

@@ -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)
})
})

View File

@@ -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 })
})
})

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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)
}
})
})

View File

@@ -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);
} }

View File

@@ -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: {

View File

@@ -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}'],
},
})