refactoring

This commit is contained in:
2026-03-12 17:23:47 +01:00
parent f5b12949d3
commit bfd1a40332
13 changed files with 179 additions and 120 deletions

View File

@@ -31,6 +31,23 @@ This doc summarizes recent improvements and suggested next steps for readability
- **CanvasContextMenuContent** (`app/canvas/CanvasContextMenuContent.tsx`): context menu content (Create Node grouped by classification, Paste). CanvasPage passes `onCreateNode` and `onPaste`.
- **useCanvasConnectionPath** (`app/canvas/useCanvasConnectionPath.ts`): all connection-path state and callbacks (updating/trigger/paused/error node ids, path node ids, start/end update, add/remove paused/error). CanvasPage calls the hook with `edges` and passes the result into FlowContext.
### 6. useCanvasGraph and canvas graph utils
- **canvasGraphUtils.ts**: `getExampleGraph()`, `getInitialGraph(projectId)` (load from storage or return example). Example nodes/edges live here.
- **useCanvasGraph(projectId)**: wraps `useGraphStateWithHistory` with initial graph from `getInitialGraph(projectId)` and debounced save to storage when `projectId` is set. CanvasPage uses this instead of inline state + persistence.
### 7. Config types registry
- **configTypes.ts**: `configTypeRegistry` (Map), `registerConfigType(type)`, `getConfigTypes()`, `getConfigTypeIds()`, `registerBuiltinConfigTypes()`. Built-in types are in `BUILTIN_CONFIG_TYPES` and registered at app init. `getConfigType(id)` looks up in the registry; `getConfigTypeId(data)` uses registered ids. ConfigNode uses `getConfigTypes()` instead of `CONFIG_TYPES`. New output types can call `registerConfigType()` without editing the core array.
### 8. Consistent NodeLike / EdgeLike
- **templateRefs.ts**: `NodeLike` includes `type?: string`; both types documented. Single source of truth for minimal node/edge shape in the graph lib.
- **sourceRenderingLogic**: context uses `NodeLike[]` and `EdgeLike[]` from templateRefs.
- **renderingSignatures**: imports and re-exports `NodeLike` / `EdgeLike` from templateRefs; no local duplicate types.
- **config/renderingLogic**: uses context nodes/edges directly (no casts); `setVarInContext(src: NodeLike)`.
- **useRenderingNodeState**: casts to `NodeLike[]` / `EdgeLike[]` when calling `buildSourceSignatures` (types from renderingSignatures).
## Design patterns in use
| Pattern | Where |
@@ -43,8 +60,4 @@ This doc summarizes recent improvements and suggested next steps for readability
## Suggested next steps
1. **CanvasPage**: Further split optional: e.g. `useCanvasGraph()` for graph state + persistence + connection rules, so the page is mostly composition and layout.
2. **Config types**: If you add more output types (e.g. Mermaid), consider a small registry API (`registerConfigType`, `getConfigType`) instead of a single large `CONFIG_TYPES` array.
3. **Consistent node shape in lib**: `templateRefs` and `renderingSignatures` use `EdgeLike` / `NodeLike`; standardize where appropriate to reduce casts.
- **CanvasPage**: Optional further split (e.g. move connection validation or node/edge change handlers into a hook) if the file grows again.

View File

@@ -26,7 +26,8 @@ import { AnimatedEdge } from '@/components/graph/AnimatedEdge'
import FlowContext from '@/lib/graph/flowContext'
import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
import { getExampleGraph } from '@/app/canvas/canvasGraphUtils'
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
@@ -48,7 +49,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import {
getRegisteredNodeTypes,
getRegisteredNodeTypeIds,
@@ -58,12 +59,7 @@ import {
} from '@/lib/graph/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { toast } from 'sonner'
import {
loadGraphFromStorage,
saveGraphToStorage,
PROJECT_FILE_EXT,
PROJECT_VERSION,
} from '@/app/pleroma/projectGraphStorage'
import { PROJECT_FILE_EXT, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 }
@@ -72,52 +68,6 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
})
const NODE_GAP = 150
const initialNodes: AppNode[] = [
{
id: 'var_001',
position: { x: 50, y: 100 },
data: { value: 'Zoe', valueType: 'string' as const },
type: 'variable',
style: DEFAULT_NODE_STYLE.variable,
},
{
id: 'cfg_001',
position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 },
data: {
plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n',
title: 'config-cfg_001',
},
type: 'config',
style: DEFAULT_NODE_STYLE.config,
},
{
id: 'rnd_001',
position: {
x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP,
y: 50,
},
data: {},
type: 'render',
style: DEFAULT_NODE_STYLE.render,
},
]
const initialEdges: AppEdge[] = [
{ 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' },
]
function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return {
nodes: initialNodes.map((n) => ({
...n,
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
})),
edges: initialEdges.map((e) => ({ ...e })),
}
}
function FlowFitViewOnLoad() {
const nodesInitialized = useNodesInitialized()
const { fitView } = useReactFlow()
@@ -196,21 +146,9 @@ export type CanvasPageProps = {
projectId?: string
}
function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (projectId) {
const stored = loadGraphFromStorage(projectId)
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
}
return { nodes: [], edges: [] }
}
return getExampleGraph()
}
export function CanvasPage({ projectId }: CanvasPageProps) {
const { theme } = useTheme()
const { showMinimap } = usePlatform()
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
const {
nodes,
edges,
@@ -225,20 +163,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
canUndo,
canRedo,
setStateImmediate,
} = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
// Persist graph to localStorage when projectId is set (debounced)
const saveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
if (!projectId) return
const save = () => {
saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes, edges })
}
saveTimeoutRef.current = setTimeout(save, 500)
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
}
}, [projectId, nodes, edges])
} = useCanvasGraph(projectId)
const importInputRef = useRef<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<unknown>(null)

View File

@@ -0,0 +1,66 @@
/**
* Canvas graph: example graph and initial graph from storage.
* Used by useCanvasGraph and CanvasPage (e.g. Load example).
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
const NODE_GAP = 150
const EXAMPLE_NODES: AppNode[] = [
{
id: 'var_001',
position: { x: 50, y: 100 },
data: { value: 'Zoe', valueType: 'string' as const },
type: 'variable',
style: DEFAULT_NODE_STYLE.variable,
},
{
id: 'cfg_001',
position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 },
data: {
plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n',
title: 'config-cfg_001',
},
type: 'config',
style: DEFAULT_NODE_STYLE.config,
},
{
id: 'rnd_001',
position: {
x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP,
y: 50,
},
data: {},
type: 'render',
style: DEFAULT_NODE_STYLE.render,
},
]
const EXAMPLE_EDGES: AppEdge[] = [
{ 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' },
]
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return {
nodes: EXAMPLE_NODES.map((n) => ({
...n,
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
})),
edges: EXAMPLE_EDGES.map((e) => ({ ...e })),
}
}
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (projectId) {
const stored = loadGraphFromStorage(projectId)
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
}
return { nodes: [], edges: [] }
}
return getExampleGraph()
}

View File

@@ -0,0 +1,33 @@
/**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from project storage (or example) and debounced save.
* Keeps CanvasPage focused on composition and layout.
*/
import { useEffect, useMemo, useRef } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!projectId) return
const save = () => {
saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes, edges })
}
saveTimeoutRef.current = setTimeout(save, 500)
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
}
}, [projectId, nodes, edges])
return result
}

View File

@@ -14,7 +14,7 @@ import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import { useTheme } from '@/lib/themeContext'
import {
CONFIG_TYPES,
getConfigTypes,
getConfigContent,
getConfigType,
getConfigTypeId,
@@ -261,7 +261,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONFIG_TYPES.map((t) => (
{getConfigTypes().map((t) => (
<SelectItem key={t.id} value={t.id} className="text-xs">
{t.label}
</SelectItem>

View File

@@ -7,10 +7,7 @@
import nunjucks from 'nunjucks'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
import { isReachable, resolveExtendsRef, getTemplateRefs } from '@/lib/graph/templateRefs'
type Node = { id: string; type?: string; data?: unknown }
type Edge = { id: string; source: string; target: string }
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
const { nodes, edges, sourceNodeId, renderNodeId } = context
@@ -24,11 +21,11 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
const configIdsUsed = new Set<string>()
const addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
const refId = resolveExtendsRef(nodes as { id: string; data?: unknown }[], templateName)
const refId = resolveExtendsRef(nodes, templateName)
if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`)
const node = nodes.find((n) => n.id === refId && n.type === 'config')
if (!node) throw new Error(`Config not found: ${templateName}`)
if (refId !== srcId && !isReachable(edges as { source: string; target: string }[], refId, id))
if (refId !== srcId && !isReachable(edges, refId, id))
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
visited.add(refId)
configIdsUsed.add(refId)
@@ -40,10 +37,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
const configLoader = {
getSource: (name: string): { src: string; path: string } | null => {
const refId = resolveExtendsRef(nodes as { id: string; data?: unknown }[], name)
const refId = resolveExtendsRef(nodes, name)
const node = nodes.find((n) => n.id === refId && n.type === 'config')
if (!node) return null
if (refId !== srcId && !isReachable(edges as { source: string; target: string }[], refId, id))
if (refId !== srcId && !isReachable(edges, refId, id))
throw new Error(`Referenced config not connected to renderer: ${name}`)
return {
src: getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined),
@@ -53,7 +50,7 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
}
const nunjucksContext = Object.create(null) as Record<string, unknown>
const setVarInContext = (src: Node) => {
const setVarInContext = (src: NodeLike) => {
const v = (src.data as Record<string, unknown>)?.value
const str = v === undefined || v === null ? '' : String(v)
nunjucksContext[src.id] =

View File

@@ -20,7 +20,7 @@ import {
processSvgDisplay,
stripTemplateSyntax,
} from '@/lib/graph/rendering'
import { buildSourceSignatures } from '@/lib/graph/renderingSignatures'
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
export type RenderingNodeData = {
@@ -137,13 +137,7 @@ export function useRenderingNodeState(
: ''
const signatures = useMemo(
() =>
buildSourceSignatures(
nodes as { id: string; type?: string; data?: unknown }[],
edges as { source: string; target: string }[],
id,
incomingIds
),
() => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds),
[nodes, edges, id, incomingIds]
)
const {

View File

@@ -144,7 +144,27 @@ async function renderWireframe(content: string, options?: RenderOptions): Promis
return svg
}
export const CONFIG_TYPES: ConfigType[] = [
/** Registry for config (output) types. Built-in types register in registerBuiltinConfigTypes(). */
const configTypeRegistry = new Map<string, ConfigType>()
export function registerConfigType(type: ConfigType): void {
if (configTypeRegistry.has(type.id)) {
console.warn(`[configTypes] Overwriting config type: ${type.id}`)
}
configTypeRegistry.set(type.id, type)
}
/** All registered config types (for type selector, etc.). */
export function getConfigTypes(): ConfigType[] {
return Array.from(configTypeRegistry.values())
}
/** All registered config type ids. */
export function getConfigTypeIds(): string[] {
return Array.from(configTypeRegistry.keys())
}
const BUILTIN_CONFIG_TYPES: ConfigType[] = [
{
id: 'plantuml',
label: 'Diagram',
@@ -220,11 +240,17 @@ export const CONFIG_TYPES: ConfigType[] = [
},
]
export const CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
export const DEFAULT_CONFIG_TYPE_ID: ConfigTypeId = 'plantuml'
export function getConfigType(id: ConfigTypeId): ConfigType {
const t = CONFIG_TYPES.find((c) => c.id === id)
/** Register built-in config types (plantuml, markdown, wireframe). Call once at app init. */
export function registerBuiltinConfigTypes(): void {
for (const t of BUILTIN_CONFIG_TYPES) {
registerConfigType(t)
}
}
export function getConfigType(id: string): ConfigType {
const t = configTypeRegistry.get(id)
if (!t) throw new Error(`Unknown config type: ${id}`)
return t
}
@@ -264,9 +290,9 @@ export function getConfigContent(data: Record<string, unknown> | undefined): str
return typeof content === 'string' ? content : ''
}
/** Config type id from data (default plantuml). */
/** Config type id from data (default plantuml). Uses registered ids. */
export function getConfigTypeId(data: Record<string, unknown> | undefined): ConfigTypeId {
if (!data || data.configType == null) return 'plantuml'
const id = data.configType
return CONFIG_TYPE_IDS.includes(id as ConfigTypeId) ? (id as ConfigTypeId) : 'plantuml'
const id = String(data.configType)
return configTypeRegistry.has(id) ? (id as ConfigTypeId) : 'plantuml'
}

View File

@@ -22,7 +22,7 @@
*
* ## Implementing a new output type
* - Add a config type (or equivalent) fulfilling `IOutputTypeRenderer`.
* - Register in CONFIG_TYPES and use `getConfigType(id)` in the resolve step.
* - Call registerConfigType(type) (e.g. from registerBuiltinConfigTypes) and use getConfigType(id) in the resolve step.
*
* ## Shared helpers
* - templateRefs.ts: isReachable, resolveExtendsRef, getTemplateRefs (Nunjucks extends/include/import).

View File

@@ -1,14 +1,13 @@
/**
* Pure functions to build "connected node ids" and source signatures for the
* rendering pipeline. Used by useRenderingNodeState so the hook stays thin and
* this logic is testable in isolation.
* this logic is testable in isolation. Uses NodeLike/EdgeLike from templateRefs.
*/
import { getConfigContent } from '@/lib/graph/rendering'
import { isReachable, resolveExtendsRef, getTemplateRefs } from '@/lib/graph/templateRefs'
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike, type EdgeLike } from '@/lib/graph/templateRefs'
export type NodeLike = { id: string; type?: string; data?: unknown }
export type EdgeLike = { source: string; target: string }
export type { NodeLike, EdgeLike } from '@/lib/graph/templateRefs'
/**
* Collects all node ids that affect the render: direct incoming nodes plus

View File

@@ -7,17 +7,18 @@
*/
import type { ConfigTypeId } from './configTypes'
import type { NodeLike, EdgeLike } from './templateRefs'
export type { OutputMenuDescriptor, OutputMenuItemDescriptor, OutputMenuAction } from './configTypes'
/** Context passed into the resolve step (graph, source id, callbacks). See {@link IRenderingContext}. */
export type SourceRenderingLogicContext = {
nodes: { id: string; type?: string; data?: unknown }[]
edges: { id: string; source: string; target: string }[]
nodes: NodeLike[]
edges: EdgeLike[]
sourceNodeId: string
renderNodeId: string
viewportWidth?: number
viewportHeight?: number
setNodes?: (updater: (nodes: { id: string; type?: string; data?: unknown }[]) => { id: string; type?: string; data?: unknown }[]) => void
setNodes?: (updater: (nodes: NodeLike[]) => NodeLike[]) => void
aiConnection?: unknown
onStreamingStart?: () => void
onStreamingChunk?: (chunk: string) => void

View File

@@ -4,8 +4,11 @@
* so template parsing and "connected configs" stay in one place.
*/
/** Minimal edge shape for graph helpers (reachability, path, signatures). */
export type EdgeLike = { source: string; target: string }
export type NodeLike = { id: string; data?: unknown }
/** Minimal node shape for graph helpers (resolve refs, signatures). */
export type NodeLike = { id: string; type?: string; data?: unknown }
/** BFS: is target reachable from start following directed edges? */
export function isReachable(

View File

@@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from 'sonner'
import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
import { KosmosPage } from './app/kosmos/KosmosPage'
import { ProjectsPage } from './app/pleroma/PleromaPage'
import { KeromaPage } from './app/keroma/KeromaPage'
@@ -11,6 +12,7 @@ import { CanvasRoute } from './app/canvas/CanvasRoute'
import './styles.css'
import '@xyflow/react/dist/style.css'
registerBuiltinConfigTypes()
registerBuiltinNodes()
createRoot(document.getElementById('root')!).render(