feat: introdce store

This commit is contained in:
2026-03-13 00:32:19 +01:00
parent 2d8f13aebb
commit fc265266c7
17 changed files with 2859 additions and 83 deletions

View File

@@ -34,7 +34,8 @@ import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
@@ -186,7 +187,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const connectionPath = useCanvasConnectionPath(edges)
useEffect(() => {
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
}, [nodes, edges])
const connectionPath = useCanvasConnectionPathFromStore()
const nodesRef = useRef(nodes)
nodesRef.current = nodes

View File

@@ -0,0 +1,31 @@
/**
* Canvas store: centralized state + commands + selectors.
* Entry point for store usage.
*/
export {
getCanvasStore,
dispatchCanvasCommand,
useCanvasStore,
useCanvasStoreDispatch,
canvasStore,
} from './canvasStore'
export type { CanvasStore, CanvasCommand } from './canvasStore'
export { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
export type { CanvasStore as CanvasStoreState, PathSlice, UISlice, GraphSlice, ConnectionFrom } from './canvasStore.types'
export {
selectGraph,
selectPath,
selectUI,
selectNodes,
selectEdges,
selectPathNodeIds,
selectPathPausedSegmentNodeIds,
selectPathActiveSegmentNodeIds,
selectConnectionStatusForEdge,
selectPathRoleForNode,
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
} from './canvasStore.selectors'
export type { ConnectionPathRole } from './canvasStore.selectors'

View File

@@ -0,0 +1,143 @@
/**
* Pure reducer for the canvas store. One command → one state transition.
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import type { CanvasStore, CanvasCommand, GraphSlice, PathSlice, UISlice } from './canvasStore.types'
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
const initialGraph: GraphSlice = {
nodes: [],
edges: [],
}
const initialPath: PathSlice = {
updatingNodeIds: [],
triggerNodeIds: [],
pausedNodeIds: [],
errorNodeIds: [],
}
const initialUI: UISlice = {
renamingNodeId: null,
fullscreenNodeId: null,
connectionFrom: null,
}
export const initialCanvasStore: CanvasStore = {
graph: initialGraph,
path: initialPath,
ui: initialUI,
}
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
function reduceGraph(prev: GraphSlice, cmd: CanvasCommand): GraphSlice {
switch (cmd.type) {
case 'graph/setNodes': {
const next =
typeof cmd.payload === 'function' ? cmd.payload(prev.nodes) : cmd.payload
return { ...prev, nodes: next }
}
case 'graph/setEdges': {
const next =
typeof cmd.payload === 'function' ? cmd.payload(prev.edges) : cmd.payload
return { ...prev, edges: next }
}
case 'graph/apply': {
return {
nodes: cmd.payload.nodes ?? prev.nodes,
edges: cmd.payload.edges ?? prev.edges,
}
}
default:
return prev
}
}
function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
switch (cmd.type) {
case 'path/addTrigger': {
const id = cmd.payload
if (prev.triggerNodeIds.includes(id)) return prev
return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] }
}
case 'path/clearTriggers':
return { ...prev, triggerNodeIds: [] }
case 'path/startUpdate': {
const id = cmd.payload
if (prev.updatingNodeIds.includes(id)) return prev
return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] }
}
case 'path/endUpdate': {
const id = cmd.payload
return {
...prev,
updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id),
}
}
case 'path/setPaused': {
const { nodeId, paused } = cmd.payload
const has = prev.pausedNodeIds.includes(nodeId)
if (paused === has) return prev
return {
...prev,
pausedNodeIds: paused
? [...prev.pausedNodeIds, nodeId]
: prev.pausedNodeIds.filter((x) => x !== nodeId),
}
}
case 'path/setError': {
const { nodeId, error } = cmd.payload
const has = prev.errorNodeIds.includes(nodeId)
if (error === has) return prev
return {
...prev,
errorNodeIds: error
? [...prev.errorNodeIds, nodeId]
: prev.errorNodeIds.filter((x) => x !== nodeId),
}
}
case 'path/clearPathSession':
return {
...initialPath,
errorNodeIds: prev.errorNodeIds,
}
case 'path/clearErrors':
return { ...prev, errorNodeIds: [] }
default:
return prev
}
}
function reduceUI(prev: UISlice, cmd: CanvasCommand): UISlice {
switch (cmd.type) {
case 'ui/setRenaming':
return { ...prev, renamingNodeId: cmd.payload }
case 'ui/setFullscreen':
return { ...prev, fullscreenNodeId: cmd.payload }
case 'ui/setConnectionFrom':
return { ...prev, connectionFrom: cmd.payload }
default:
return prev
}
}
export function canvasStoreReducer(state: CanvasStore, command: CanvasCommand): CanvasStore {
const graph = reduceGraph(state.graph, command)
const path = reducePath(state.path, command)
const ui = reduceUI(state.ui, command)
if (
graph === state.graph &&
path === state.path &&
ui === state.ui
) {
return state
}
return { graph, path, ui }
}

View File

@@ -0,0 +1,129 @@
/**
* Selectors for the canvas store. Pure (state) => value.
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
*/
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
import type { CanvasStore } from './canvasStore.types'
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null
// ---------------------------------------------------------------------------
// Raw slices
// ---------------------------------------------------------------------------
export function selectGraph(state: CanvasStore) {
return state.graph
}
export function selectPath(state: CanvasStore) {
return state.path
}
export function selectUI(state: CanvasStore) {
return state.ui
}
export function selectNodes(state: CanvasStore) {
return state.graph.nodes
}
export function selectEdges(state: CanvasStore) {
return state.graph.edges
}
// ---------------------------------------------------------------------------
// Derived path (Sets) depend on graph.edges + path primitive arrays
// ---------------------------------------------------------------------------
const emptySet = new Set<string>()
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
return edges.map((e) => ({ source: e.source, target: e.target }))
}
export function selectPathNodeIds(state: CanvasStore): Set<string> {
const { edges } = state.graph
const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path
return getPathNodeIds(
edgesAsGraphEdges(edges),
updatingNodeIds,
triggerNodeIds,
pausedNodeIds
)
}
export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set<string> {
const pathNodeIds = selectPathNodeIds(state)
const { edges } = state.graph
const { triggerNodeIds, pausedNodeIds } = state.path
return getPausedSegmentNodeIds(
edgesAsGraphEdges(edges),
pathNodeIds,
triggerNodeIds,
pausedNodeIds
)
}
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
const pathNodeIds = selectPathNodeIds(state)
const pausedSegment = selectPathPausedSegmentNodeIds(state)
const active = new Set(pathNodeIds)
pausedSegment.forEach((id) => active.delete(id))
return active
}
// ---------------------------------------------------------------------------
// Per-edge connection status (for AnimatedEdge)
// ---------------------------------------------------------------------------
export function selectConnectionStatusForEdge(
state: CanvasStore,
source: string,
target: string
): ConnectionStatus {
const pathNodeIds = selectPathNodeIds(state)
const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state)
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
return getConnectionStatus({
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
})
}
// ---------------------------------------------------------------------------
// Per-node path role (for BaseNode styling)
// ---------------------------------------------------------------------------
export function selectPathRoleForNode(
state: CanvasStore,
nodeId: string
): ConnectionPathRole {
const pathNodeIds = selectPathNodeIds(state)
if (!pathNodeIds.has(nodeId)) return null
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
if (state.path.updatingNodeIds.includes(nodeId)) return 'updating'
return 'on-path'
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
export function selectRenamingNodeId(state: CanvasStore): string | null {
return state.ui.renamingNodeId
}
export function selectFullscreenNodeId(state: CanvasStore): string | null {
return state.ui.fullscreenNodeId
}
export function selectConnectionFrom(state: CanvasStore) {
return state.ui.connectionFrom
}

View File

@@ -0,0 +1,399 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
initialCanvasStore,
canvasStoreReducer,
getCanvasStore,
dispatchCanvasCommand,
} from './canvasStore'
import {
selectPathNodeIds,
selectPathPausedSegmentNodeIds,
selectPathActiveSegmentNodeIds,
selectConnectionStatusForEdge,
selectPathRoleForNode,
selectNodes,
selectEdges,
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
} from './canvasStore.selectors'
import type { CanvasStore, CanvasCommand } from './canvasStore.types'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeNode(id: string, type = 'config'): AppNode {
return {
id,
type,
position: { x: 0, y: 0 },
data: {},
}
}
function makeEdge(id: string, source: string, target: string): AppEdge {
return { id, source, target }
}
// ---------------------------------------------------------------------------
// Reducer: graph commands
// ---------------------------------------------------------------------------
describe('canvasStoreReducer', () => {
describe('graph commands', () => {
it('graph/setNodes replaces nodes', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setNodes',
payload: [makeNode('b'), makeNode('c')],
})
expect(next.graph.nodes).toHaveLength(2)
expect(next.graph.nodes.map((n) => n.id)).toEqual(['b', 'c'])
expect(next.graph.edges).toEqual(state.graph.edges)
})
it('graph/setNodes with updater function', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a'), makeNode('b')],
edges: [],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setNodes',
payload: (prev) => prev.filter((n) => n.id !== 'a'),
})
expect(next.graph.nodes).toHaveLength(1)
expect(next.graph.nodes[0].id).toBe('b')
})
it('graph/setEdges replaces edges', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setEdges',
payload: [makeEdge('e2', 'b', 'c')],
})
expect(next.graph.edges).toHaveLength(1)
expect(next.graph.edges[0].id).toBe('e2')
})
it('graph/apply updates nodes and edges', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [makeEdge('e1', 'a', 'b')],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/apply',
payload: { nodes: [makeNode('x')] },
})
expect(next.graph.nodes).toHaveLength(1)
expect(next.graph.nodes[0].id).toBe('x')
expect(next.graph.edges).toEqual(state.graph.edges)
})
})
describe('path commands', () => {
it('path/addTrigger adds node id', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next.path.triggerNodeIds).toEqual(['n1'])
})
it('path/addTrigger is idempotent', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['n1'] },
}
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next).toBe(state)
})
it('path/clearTriggers empties triggerNodeIds', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['a', 'b'] },
}
const next = canvasStoreReducer(state, { type: 'path/clearTriggers' })
expect(next.path.triggerNodeIds).toEqual([])
})
it('path/startUpdate adds to updatingNodeIds', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'path/startUpdate',
payload: 'n1',
})
expect(next.path.updatingNodeIds).toEqual(['n1'])
})
it('path/endUpdate removes from updatingNodeIds', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: {
...initialCanvasStore.path,
updatingNodeIds: ['n1', 'n2'],
},
}
const next = canvasStoreReducer(state, {
type: 'path/endUpdate',
payload: 'n1',
})
expect(next.path.updatingNodeIds).toEqual(['n2'])
})
it('path/setPaused adds and removes paused node', () => {
const state: CanvasStore = { ...initialCanvasStore }
let next = canvasStoreReducer(state, {
type: 'path/setPaused',
payload: { nodeId: 'n1', paused: true },
})
expect(next.path.pausedNodeIds).toEqual(['n1'])
next = canvasStoreReducer(next, {
type: 'path/setPaused',
payload: { nodeId: 'n1', paused: false },
})
expect(next.path.pausedNodeIds).toEqual([])
})
it('path/setError adds and removes error node', () => {
const state: CanvasStore = { ...initialCanvasStore }
let next = canvasStoreReducer(state, {
type: 'path/setError',
payload: { nodeId: 'n1', error: true },
})
expect(next.path.errorNodeIds).toEqual(['n1'])
next = canvasStoreReducer(next, {
type: 'path/setError',
payload: { nodeId: 'n1', error: false },
})
expect(next.path.errorNodeIds).toEqual([])
})
it('path/clearPathSession resets updating, trigger, paused; keeps error', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: {
updatingNodeIds: ['u1'],
triggerNodeIds: ['t1'],
pausedNodeIds: ['p1'],
errorNodeIds: ['e1'],
},
}
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
expect(next.path.updatingNodeIds).toEqual([])
expect(next.path.triggerNodeIds).toEqual([])
expect(next.path.pausedNodeIds).toEqual([])
expect(next.path.errorNodeIds).toEqual(['e1'])
})
})
describe('ui commands', () => {
it('ui/setRenaming updates renamingNodeId', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setRenaming',
payload: 'node-1',
})
expect(next.ui.renamingNodeId).toBe('node-1')
})
it('ui/setFullscreen updates fullscreenNodeId', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setFullscreen',
payload: 'node-2',
})
expect(next.ui.fullscreenNodeId).toBe('node-2')
})
it('ui/setConnectionFrom updates connectionFrom', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setConnectionFrom',
payload: { nodeId: 'n1', sourceHandle: 'out' },
})
expect(next.ui.connectionFrom).toEqual({ nodeId: 'n1', sourceHandle: 'out' })
})
})
describe('no-op returns same reference', () => {
it('path/addTrigger with existing id returns state', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['a'] },
}
const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'a' })
expect(next).toBe(state)
})
})
})
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
describe('canvasStore selectors', () => {
it('selectNodes and selectEdges return graph slice', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [makeEdge('e1', 'a', 'b')],
},
}
expect(selectNodes(state)).toHaveLength(1)
expect(selectEdges(state)).toHaveLength(1)
})
it('selectPathNodeIds derives path from edges and path arrays', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a'), makeNode('b'), makeNode('c')],
edges: [
makeEdge('e1', 'a', 'b'),
makeEdge('e2', 'b', 'c'),
],
},
path: {
updatingNodeIds: ['c'],
triggerNodeIds: ['a'],
pausedNodeIds: [],
errorNodeIds: [],
},
}
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('a')).toBe(true)
expect(pathIds.has('b')).toBe(true)
expect(pathIds.has('c')).toBe(true)
})
it('selectConnectionStatusForEdge returns default when edge not on path', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')] },
path: initialCanvasStore.path,
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
})
it('selectConnectionStatusForEdge returns error when target has error', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
...initialCanvasStore.path,
triggerNodeIds: ['a'],
updatingNodeIds: ['b'],
pausedNodeIds: [],
errorNodeIds: ['b'],
},
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
})
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
...initialCanvasStore.path,
triggerNodeIds: ['a'],
updatingNodeIds: ['b'],
pausedNodeIds: [],
errorNodeIds: [],
},
}
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
expect(selectPathRoleForNode(state, 'x')).toBe(null)
})
it('selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom return ui slice', () => {
const state: CanvasStore = {
...initialCanvasStore,
ui: {
renamingNodeId: 'r1',
fullscreenNodeId: 'f1',
connectionFrom: { nodeId: 'c1' },
},
}
expect(selectRenamingNodeId(state)).toBe('r1')
expect(selectFullscreenNodeId(state)).toBe('f1')
expect(selectConnectionFrom(state)).toEqual({ nodeId: 'c1' })
})
})
// ---------------------------------------------------------------------------
// Store integration (dispatch + getState)
// ---------------------------------------------------------------------------
describe('canvas store integration', () => {
beforeEach(() => {
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [] })
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [] })
dispatchCanvasCommand({ type: 'path/clearPathSession' })
dispatchCanvasCommand({ type: 'path/clearTriggers' })
dispatchCanvasCommand({ type: 'path/clearErrors' })
dispatchCanvasCommand({ type: 'ui/setRenaming', payload: null })
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: null })
dispatchCanvasCommand({ type: 'ui/setConnectionFrom', payload: null })
})
it('dispatch graph/setNodes updates getCanvasStore().graph.nodes', () => {
const node = makeNode('test-1')
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [node] })
const state = getCanvasStore()
expect(state.graph.nodes).toHaveLength(1)
expect(state.graph.nodes[0].id).toBe('test-1')
})
it('dispatch path/addTrigger updates path and selectPathNodeIds', () => {
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
const state = getCanvasStore()
expect(state.path.triggerNodeIds).toContain('a')
expect(state.path.updatingNodeIds).toContain('b')
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('a')).toBe(true)
expect(pathIds.has('b')).toBe(true)
})
it('dispatch ui/setFullscreen updates getCanvasStore().ui', () => {
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: 'full-node' })
const state = getCanvasStore()
expect(state.ui.fullscreenNodeId).toBe('full-node')
})
})

View File

@@ -0,0 +1,74 @@
/**
* Centralized canvas store (Zustand). Single source of truth for graph, path, and UI.
* Mutate only via dispatch(command); read via useCanvasStore(selector) or getCanvasStore().
*/
import { createStore, useStore } from 'zustand'
import type { CanvasStore as CanvasStoreState, CanvasCommand } from './canvasStore.types'
import { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
// ---------------------------------------------------------------------------
// Store type (state + dispatch)
// ---------------------------------------------------------------------------
export type CanvasStoreWithDispatch = CanvasStoreState & {
dispatch: (command: CanvasCommand) => void
}
// ---------------------------------------------------------------------------
// Create store
// ---------------------------------------------------------------------------
function createCanvasStore() {
return createStore<CanvasStoreWithDispatch>((set, get) => {
const applyCommand = (command: CanvasCommand) => {
const prev = get()
const next = canvasStoreReducer(prev, command)
if (next === prev) return
set({ ...next, dispatch: prev.dispatch })
}
const dispatch: (command: CanvasCommand) => void =
typeof import.meta !== 'undefined' && import.meta.env?.DEV
? (command) => {
// eslint-disable-next-line no-console
console.log('[canvas]', command.type, command.payload)
applyCommand(command)
}
: applyCommand
return { ...initialCanvasStore, dispatch }
})
}
const canvasStore = createCanvasStore()
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Get current state (for use outside React or in selectors). Excludes dispatch. */
export function getCanvasStore(): CanvasStoreState {
const s = canvasStore.getState()
return { graph: s.graph, path: s.path, ui: s.ui }
}
/** Dispatch a command. Use for all mutations. */
export function dispatchCanvasCommand(command: CanvasCommand): void {
canvasStore.getState().dispatch(command)
}
/**
* Subscribe to the store. Pass a selector to re-render only when the selected value changes.
* For object/array selectors consider useShallow from 'zustand/react/shallow' to avoid unnecessary re-renders.
*/
export function useCanvasStore<T>(selector: (state: CanvasStoreState) => T): T {
return useStore(canvasStore, selector)
}
/** Hook that returns dispatch (stable reference). */
export function useCanvasStoreDispatch(): (command: CanvasCommand) => void {
return useStore(canvasStore, (s) => s.dispatch, Object.is)
}
export { canvasStore, initialCanvasStore, canvasStoreReducer }
export type { CanvasStoreState as CanvasStore }
export type { CanvasCommand }

View File

@@ -0,0 +1,68 @@
/**
* Canvas store: types for the centralized store (graph + path + ui).
* All mutations go through commands; reads go through selectors.
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
// ---------------------------------------------------------------------------
// Graph slice
// ---------------------------------------------------------------------------
export type GraphSlice = {
nodes: AppNode[]
edges: AppEdge[]
}
// ---------------------------------------------------------------------------
// Path slice (primitive arrays; derived Sets are in selectors)
// ---------------------------------------------------------------------------
export type PathSlice = {
updatingNodeIds: string[]
triggerNodeIds: string[]
pausedNodeIds: string[]
errorNodeIds: string[]
}
// ---------------------------------------------------------------------------
// UI slice
// ---------------------------------------------------------------------------
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type UISlice = {
renamingNodeId: string | null
fullscreenNodeId: string | null
connectionFrom: ConnectionFrom
}
// ---------------------------------------------------------------------------
// Full store
// ---------------------------------------------------------------------------
export type CanvasStore = {
graph: GraphSlice
path: PathSlice
ui: UISlice
}
// ---------------------------------------------------------------------------
// Commands (discriminated union)
// ---------------------------------------------------------------------------
export type CanvasCommand =
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
| { type: 'path/addTrigger'; payload: string }
| { type: 'path/clearTriggers' }
| { type: 'path/startUpdate'; payload: string }
| { type: 'path/endUpdate'; payload: string }
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
| { type: 'path/clearPathSession' }
| { type: 'path/clearErrors' }
| { type: 'ui/setRenaming'; payload: string | null }
| { type: 'ui/setFullscreen'; payload: string | null }
| { type: 'ui/setConnectionFrom'; payload: ConnectionFrom }

View File

@@ -0,0 +1,154 @@
/**
* Connection-path state and callbacks backed by the canvas store.
* Replaces useCanvasConnectionPath when the store is the source of truth for path.
* Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges.
*
* Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo
* so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
dispatchCanvasCommand,
useCanvasStore,
} from '@/app/canvas/canvasStore'
import {
selectPathRoleForNode,
type ConnectionPathRole,
} from '@/app/canvas/canvasStore.selectors'
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
function edgesAsGraphEdges(
edges: Array<{ source: string; target: string }>
): Array<{ source: string; target: string }> {
return edges.map((e) => ({ source: e.source, target: e.target }))
}
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
const path = useCanvasStore((s) => s.path)
const edges = useCanvasStore((s) => s.graph.edges)
const pathNodeIds = useMemo(
() =>
getPathNodeIds(
edgesAsGraphEdges(edges),
path.updatingNodeIds,
path.triggerNodeIds,
path.pausedNodeIds
),
[
edges,
path.updatingNodeIds,
path.triggerNodeIds,
path.pausedNodeIds,
]
)
const connectionPathPausedSegmentNodeIds = useMemo(
() =>
getPausedSegmentNodeIds(
edgesAsGraphEdges(edges),
pathNodeIds,
path.triggerNodeIds,
path.pausedNodeIds
),
[edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds]
)
const connectionPathActiveSegmentNodeIds = useMemo(() => {
const active = new Set(pathNodeIds)
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
return active
}, [pathNodeIds, connectionPathPausedSegmentNodeIds])
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length)
const startConnectionPathUpdate = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
}, [])
const endConnectionPathUpdate = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
}, [])
const addConnectionPathTrigger = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
}, [])
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setPaused',
payload: { nodeId, paused: true },
})
}, [])
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setPaused',
payload: { nodeId, paused: false },
})
}, [])
const addConnectionPathError = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setError',
payload: { nodeId, error: true },
})
}, [])
const removeConnectionPathError = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setError',
payload: { nodeId, error: false },
})
}, [])
useEffect(() => {
const prev = prevUpdatingLengthRef.current
const now = path.updatingNodeIds.length
prevUpdatingLengthRef.current = now
if (prev > 0 && now === 0) {
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
}
pathUpdateEndTimeoutRef.current = setTimeout(() => {
pathUpdateEndTimeoutRef.current = null
dispatchCanvasCommand({ type: 'path/clearPathSession' })
}, CONNECTION_PATH_UPDATE_TAIL_MS)
}
return () => {
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
}
}
}, [path.updatingNodeIds.length])
return {
connectionPathUpdatingNodeIds: path.updatingNodeIds,
connectionPathTriggerNodeIds: path.triggerNodeIds,
connectionPathPausedNodeIds: path.pausedNodeIds,
connectionPathErrorNodeIds: path.errorNodeIds,
connectionPathNodeIds: pathNodeIds,
connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
addConnectionPathTrigger,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
addConnectionPathError,
removeConnectionPathError,
}
}
/**
* Path role for a node (trigger / updating / on-path) from the store.
* Use in node components so they only re-render when their path role changes.
*/
export function useConnectionPathRoleFromStore(nodeId: string | undefined): ConnectionPathRole {
return useCanvasStore((s) =>
nodeId != null ? selectPathRoleForNode(s, nodeId) : null
)
}