feat: add node help system and registry for extensible node types
- Implemented a help system for different node types (config, render, variable, function) with detailed usage instructions. - Created a node registry to manage node types, including registration, retrieval, and validation of connections between nodes. - Defined central node and edge types for the application to streamline state management. - Added Nunjucks autocomplete functionality to enhance user experience in template editing. - Developed a syntax highlighting parser for PlantUML and Nunjucks within the CodeMirror editor. - Registered built-in node types at application startup, including their default configurations and help entries. - Introduced a theme context provider to manage light/dark mode preferences across the application. - Created utility functions for class name management using clsx and tailwind-merge. - Set up Tailwind CSS for styling with custom themes and responsive design. - Configured Vite for development with proxy settings for backend API calls and Kroki diagram service.
This commit is contained in:
135
frontend/src/lib/abstractNode.ts
Normal file
135
frontend/src/lib/abstractNode.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Abstract node layer: shared types, hook, and factory for flow node components.
|
||||
*
|
||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds.
|
||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||
*
|
||||
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
|
||||
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import FlowContext from './flowContext'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Props passed by React Flow to custom node components. Extend data with your node's shape. */
|
||||
export type AbstractNodeProps<TData = Record<string, unknown>> = {
|
||||
id: string
|
||||
data: TData
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/** Edge shape used in flow context (minimal for connection logic). */
|
||||
export type FlowEdge = { id: string; source: string; target: string; [k: string]: unknown }
|
||||
|
||||
/** Node shape used in flow context (minimal for reading graph). */
|
||||
export type FlowNode = { id: string; type?: string; data?: unknown; position?: { x: number; y: number }; [k: string]: unknown }
|
||||
|
||||
/** Result of useAbstractNode: flow context plus helpers scoped to this node. */
|
||||
export type AbstractNodeContext<TData = Record<string, unknown>> = {
|
||||
id: string
|
||||
data: TData
|
||||
nodes: FlowNode[]
|
||||
edges: FlowEdge[]
|
||||
setNodes: (updater: (nodes: FlowNode[]) => FlowNode[]) => void
|
||||
setEdges: (updater: (edges: FlowEdge[]) => FlowEdge[]) => void
|
||||
/** Merge partial data into this node's data. Stable reference. */
|
||||
updateData: (partial: Partial<TData>) => void
|
||||
/** Incoming edge IDs (edges whose target is this node). */
|
||||
incomingEdges: FlowEdge[]
|
||||
/** Outgoing edge IDs (edges whose source is this node). */
|
||||
outgoingEdges: FlowEdge[]
|
||||
/** Source node IDs connected to this node (incoming). */
|
||||
sourceIds: string[]
|
||||
/** Target node IDs this node connects to (outgoing). */
|
||||
targetIds: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provides flow context and helpers for the current node. Use in any node component
|
||||
* that receives id and data; updateData(partial) merges into this node's data.
|
||||
*/
|
||||
export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
id: string,
|
||||
data: TData
|
||||
): AbstractNodeContext<TData> {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const edges = ctx?.edges ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
setNodes((nds: FlowNode[]) =>
|
||||
nds.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
)
|
||||
)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
() => (edges as FlowEdge[]).filter((e) => e.target === id),
|
||||
[edges, id]
|
||||
)
|
||||
const outgoingEdges = useMemo(
|
||||
() => (edges as FlowEdge[]).filter((e) => e.source === id),
|
||||
[edges, id]
|
||||
)
|
||||
const sourceIds = useMemo(
|
||||
() => incomingEdges.map((e) => e.source).sort(),
|
||||
[incomingEdges]
|
||||
)
|
||||
const targetIds = useMemo(
|
||||
() => outgoingEdges.map((e) => e.target).sort(),
|
||||
[outgoingEdges]
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
data,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes: setNodes ?? (() => {}),
|
||||
setEdges: setEdges ?? (() => {}),
|
||||
updateData,
|
||||
incomingEdges,
|
||||
outgoingEdges,
|
||||
sourceIds,
|
||||
targetIds,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a node component with React.memo and nodePropsAreEqual so only id/data/width/height/selected
|
||||
* changes trigger re-renders. Use with AbstractNodeProps<TData> for typed props.
|
||||
*/
|
||||
export function createAbstractNodeComponent<TData = Record<string, unknown>>(
|
||||
displayName: string,
|
||||
Component: React.ComponentType<AbstractNodeProps<TData>>
|
||||
): React.MemoExoticComponent<React.ComponentType<AbstractNodeProps<TData>>> {
|
||||
const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent<
|
||||
React.ComponentType<AbstractNodeProps<TData>>
|
||||
>
|
||||
Wrapped.displayName = displayName
|
||||
return Wrapped
|
||||
}
|
||||
232
frontend/src/lib/configTypes.ts
Normal file
232
frontend/src/lib/configTypes.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Registry of config node types. Each type defines syntax highlighting,
|
||||
* insert-menu blocks, and how to render the content (after Nunjucks) for the renderer.
|
||||
* Add a new entry here and wire its language in ConfigNode to add a new config type.
|
||||
*/
|
||||
|
||||
export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe'
|
||||
|
||||
/** A single insert option (label + snippet to insert at cursor). */
|
||||
export type InsertBlock = { label: string; snippet: string }
|
||||
|
||||
/** A group of insert options (e.g. "Diagram" with startuml, actor, etc.). */
|
||||
export type InsertBlockGroup = { label: string; items: InsertBlock[] }
|
||||
|
||||
export type InsertBlockOrGroup = InsertBlock | InsertBlockGroup
|
||||
|
||||
function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
|
||||
return 'items' in b && Array.isArray((b as InsertBlockGroup).items)
|
||||
}
|
||||
|
||||
/** Optional options passed to render (e.g. node size for wireframe SVG). */
|
||||
export type RenderOptions = { width?: number; height?: number }
|
||||
|
||||
export type ConfigType = {
|
||||
id: ConfigTypeId
|
||||
label: string
|
||||
/** CodeMirror language key; used to pick the extension in ConfigNode. */
|
||||
language: ConfigTypeId
|
||||
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
|
||||
insertBlocks: InsertBlockOrGroup[]
|
||||
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
|
||||
render: (content: string, options?: RenderOptions) => Promise<string>
|
||||
}
|
||||
|
||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||
const KROKI_TIMEOUT_MS = 15000
|
||||
|
||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||
{ label: 'Actor', snippet: 'actor ' },
|
||||
{ label: 'Participant', snippet: 'participant "" as ' },
|
||||
{ label: 'Arrow', snippet: ' -> ' },
|
||||
{ label: 'Note', snippet: 'note right of ' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
{ label: 'extends', snippet: '{% extends "" %}' },
|
||||
{ label: 'include', snippet: '{% include "" %}' },
|
||||
{ label: 'import', snippet: '{% import "" as %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Heading 1', snippet: '# ' },
|
||||
{ label: 'Heading 2', snippet: '## ' },
|
||||
{ label: 'Heading 3', snippet: '### ' },
|
||||
{ label: 'Bold', snippet: '****' },
|
||||
{ label: 'Italic', snippet: '**' },
|
||||
{ label: 'Code inline', snippet: '``' },
|
||||
{ label: 'Code block', snippet: '```\n\n```' },
|
||||
{ label: 'Link', snippet: '[]()' },
|
||||
{ label: 'Image', snippet: '![]()' },
|
||||
{ label: 'List item', snippet: '- ' },
|
||||
{ label: 'Blockquote', snippet: '> ' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Wireweave DSL: https://github.com/wireweave/core */
|
||||
const WIREFRAME_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Page', snippet: 'page "Title" {\n \n}' },
|
||||
{ label: 'Card', snippet: 'card p=4 {\n \n}' },
|
||||
{ label: 'Title', snippet: 'title "' },
|
||||
{ label: 'Text', snippet: 'text "' },
|
||||
{ label: 'Button', snippet: 'button "Label"' },
|
||||
{ label: 'Primary button', snippet: 'button "Label" primary' },
|
||||
{ label: 'Input', snippet: 'input placeholder=""' },
|
||||
{ label: 'Row', snippet: 'row {\n col span=6 { }\n}' },
|
||||
{ label: 'Col', snippet: 'col span=6 { }' },
|
||||
{ label: 'Header / Main / Footer', snippet: 'header { }\nmain { }\nfooter { }' },
|
||||
{ label: 'Image', snippet: 'image "" w=400 h=300' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
|
||||
async function renderMarkdown(content: string): Promise<string> {
|
||||
const { marked } = await import('marked')
|
||||
const html = typeof marked.parse === 'function' ? await marked.parse(content) : (marked as (s: string) => string)(content)
|
||||
return typeof html === 'string' ? html : String(html)
|
||||
}
|
||||
|
||||
/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string, options?: RenderOptions): Promise<string> {
|
||||
const { parse, renderToSvg } = await import('@wireweave/core')
|
||||
const doc = parse(content)
|
||||
const isDark =
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement?.classList?.contains('dark')
|
||||
const { svg } = renderToSvg(doc, {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
width: options?.width ?? 1200,
|
||||
height: options?.height,
|
||||
padding: 24,
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
export const CONFIG_TYPES: ConfigType[] = [
|
||||
{
|
||||
id: 'plantuml',
|
||||
label: 'Diagram',
|
||||
language: 'plantuml',
|
||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||
render: async (content: string) => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
if (res.status >= 500) {
|
||||
throw new Error('Diagram service unavailable. Try again later.')
|
||||
}
|
||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||
}
|
||||
return res.text()
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
||||
}
|
||||
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
||||
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
language: 'markdown',
|
||||
insertBlocks: MARKDOWN_INSERT_BLOCKS,
|
||||
render: renderMarkdown,
|
||||
},
|
||||
{
|
||||
id: 'wireframe',
|
||||
label: 'Wireframe',
|
||||
language: 'markdown',
|
||||
insertBlocks: WIREFRAME_INSERT_BLOCKS,
|
||||
render: renderWireframe,
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
if (!t) throw new Error(`Unknown config type: ${id}`)
|
||||
return t
|
||||
}
|
||||
|
||||
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
|
||||
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
|
||||
if (configTypeId === 'markdown') return ''
|
||||
if (configTypeId === 'wireframe') {
|
||||
return `page "Hello" {
|
||||
card p=4 {
|
||||
title "Welcome"
|
||||
text "Hello, wireweave!"
|
||||
button "Get Started" primary
|
||||
}
|
||||
}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Flatten insert blocks for iteration: either a single block or a group's items. */
|
||||
export function* iterateInsertBlocks(blocks: InsertBlockOrGroup[]): Generator<InsertBlock> {
|
||||
for (const b of blocks) {
|
||||
if (isGroup(b)) {
|
||||
for (const item of b.items) yield item
|
||||
} else {
|
||||
yield b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { isGroup }
|
||||
|
||||
/** Content from config node data (backward compat: content ?? plantuml). */
|
||||
export function getConfigContent(data: Record<string, unknown> | undefined): string {
|
||||
if (!data) return ''
|
||||
const content = data.content ?? data.plantuml
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
/** Config type id from data (default plantuml). */
|
||||
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'
|
||||
}
|
||||
29
frontend/src/lib/flowContext.tsx
Normal file
29
frontend/src/lib/flowContext.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import type { Connection } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
export type FlowActions = {
|
||||
pasteAtViewportCenter: () => void
|
||||
fitView: () => void
|
||||
}
|
||||
|
||||
export type FlowContextValue = {
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
renamingNodeId: string | null
|
||||
setRenamingNodeId: (id: string | null) => void
|
||||
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */
|
||||
connectionFrom: ConnectionFrom
|
||||
setConnectionFrom: (v: ConnectionFrom) => void
|
||||
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)
|
||||
|
||||
export default FlowContext
|
||||
97
frontend/src/lib/flowUtils.ts
Normal file
97
frontend/src/lib/flowUtils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Use as second argument to React.memo() for node components.
|
||||
* Skips re-render when only position (or other unrelated props) changed,
|
||||
* so dragging one node doesn't force other nodes to re-render.
|
||||
*/
|
||||
|
||||
import {
|
||||
getIdPrefix,
|
||||
getDefaultDataForType as getDefaultDataFromRegistry,
|
||||
getResetDataForType as getResetDataFromRegistry,
|
||||
getDefaultStyle,
|
||||
} from './nodeRegistry'
|
||||
|
||||
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||
prev: P,
|
||||
next: P
|
||||
): boolean {
|
||||
return (
|
||||
prev.id === next.id &&
|
||||
prev.data === next.data &&
|
||||
prev.width === next.width &&
|
||||
prev.height === next.height &&
|
||||
prev.selected === next.selected
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use getIdPrefix from nodeRegistry for new code. Kept for compatibility. */
|
||||
export const PREFIX_BY_TYPE: Record<string, string> = {
|
||||
config: 'cfg_',
|
||||
render: 'rnd_',
|
||||
variable: 'var_',
|
||||
function: 'fn_',
|
||||
}
|
||||
|
||||
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
|
||||
export function getNextNodeId(type: string, existingIds: string[]): string {
|
||||
const prefix = getIdPrefix(type)
|
||||
const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
|
||||
let max = 0
|
||||
for (const id of existingIds) {
|
||||
const m = id.match(re)
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10))
|
||||
}
|
||||
return `${prefix}${String(max + 1).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/** Recursively replace oldId with newId in string values (for rename propagation) */
|
||||
function replaceInData(value: unknown, oldId: string, newId: string): unknown {
|
||||
if (typeof value === 'string') return value.split(oldId).join(newId)
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (Array.isArray(value)) return value.map((v) => replaceInData(v, oldId, newId))
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = replaceInData(v, oldId, newId)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Update graph after renaming a node: change node id and all references in data and edges */
|
||||
export function replaceNodeIdInGraph(
|
||||
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
|
||||
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
|
||||
oldId: string,
|
||||
newId: string
|
||||
): { nodes: typeof nodes; edges: typeof edges } {
|
||||
const newNodes = nodes.map((n) =>
|
||||
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
|
||||
)
|
||||
const newEdges = edges.map((e) => ({
|
||||
...e,
|
||||
id: e.id.includes(oldId) ? e.id.split(oldId).join(newId) : e.id,
|
||||
source: e.source === oldId ? newId : e.source,
|
||||
target: e.target === oldId ? newId : e.target,
|
||||
}))
|
||||
return { nodes: newNodes, edges: newEdges }
|
||||
}
|
||||
|
||||
/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */
|
||||
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
|
||||
config: { width: 320, height: 320 },
|
||||
render: { width: 384, height: 320 },
|
||||
variable: { width: 224, height: 180 },
|
||||
function: { width: 288, height: 260 },
|
||||
}
|
||||
|
||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): any {
|
||||
return getDefaultDataFromRegistry(type, newId)
|
||||
}
|
||||
|
||||
/** Data for Reset action. Uses nodeRegistry when type is registered. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): any {
|
||||
return getResetDataFromRegistry(type, nodeId)
|
||||
}
|
||||
|
||||
/** Default style for a type. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultStyleForType(type: string): { width: number; height: number } {
|
||||
return getDefaultStyle(type)
|
||||
}
|
||||
84
frontend/src/lib/nodeHelp.tsx
Normal file
84
frontend/src/lib/nodeHelp.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type React from 'react'
|
||||
|
||||
export type NodeType = 'config' | 'render' | 'variable' | 'function'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
const Code = ({ children }: { children: React.ReactNode }) => (
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">{children}</code>
|
||||
)
|
||||
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div className="mt-3 first:mt-0">
|
||||
<h4 className="text-xs font-semibold text-foreground">{title}</h4>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
|
||||
config: {
|
||||
title: 'Config node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Config nodes hold PlantUML + Nunjucks template content. Connect one config to a Render node to display the diagram. Use the editor to write <Code>@startuml</Code> blocks and Nunjucks tags (<Code>{'{{ }}'}</Code>, <Code>{'{% %}'}</Code>).</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>From another config, reference this template:</p>
|
||||
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||
<li><Code>{'{% extends "configId" %}'}</Code> — inherit layout</li>
|
||||
<li><Code>{'{% include "configId" %}'}</Code> — inline content</li>
|
||||
<li><Code>{'{% import "configId" as alias %}'}</Code> — use as macro namespace</li>
|
||||
</ul>
|
||||
<p className="mt-2">Replace <Code>configId</Code> with this node’s id or its title. Connect variables/functions to this config; they are available as <Code>{'{{ varId }}'}</Code> and <Code>{'{{ x | fnId }}'}</Code> in the template.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
render: {
|
||||
title: 'Renderer node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Connect a single Config node (input) to this Render node. It resolves the config’s PlantUML + Nunjucks (variables, function filters, extends/include), sends the result to the diagram service, and shows the SVG here.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>Renderer nodes are terminal: they only consume configs. They are not referenced from other nodes. To reuse a diagram, reference the Config node from another Config (extends/include), then connect that config to a Render node.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
variable: {
|
||||
title: 'Variable node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Variables hold a value (string, number, or boolean). Connect a Variable node to a Config node to expose it in that config’s template.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>In a Config template connected to this variable, use <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> where <em>nodeId</em> is this node’s id. Example: if the variable node id is <Code>var_001</Code>, write <Code>{'{{ var_001 }}'}</Code> in the config.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
function: {
|
||||
title: 'Function node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Functions are Nunjucks custom filters. Write a body using either named parameters (<Code>function(num, x, kwargs) { ... }</Code>) or the <Code>args</Code> array. Connect this node to a Config to use the filter in that config’s template.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>In a Config template, use the filter syntax: <Code>{'{{ value | '}<em>nodeId</em>{' }}'}</Code> or <Code>{'{{ value | '}<em>nodeId</em>{'(arg1, key=val) }}'}</Code>. The first argument is the value before <Code>|</Code>; extra arguments and keyword args are passed as in Nunjucks. Return a value or a Promise for async filters.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {
|
||||
return NODE_HELP[nodeType] ?? { title: nodeType, content: null }
|
||||
}
|
||||
116
frontend/src/lib/nodeRegistry.ts
Normal file
116
frontend/src/lib/nodeRegistry.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Extensible node type registry. Register node types with registerNodeType();
|
||||
* built-in types are registered in registerBuiltinNodes.ts.
|
||||
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
export type NodeTypeDescriptor = {
|
||||
id: string
|
||||
component: React.ComponentType<any>
|
||||
defaultStyle: { width: number; height: number }
|
||||
defaultData: Record<string, unknown>
|
||||
idPrefix: string
|
||||
hasInput: boolean
|
||||
hasOutput: boolean
|
||||
/** When this type is the connection target, which source types are allowed. Omit = all. */
|
||||
allowedSourceTypes?: string[]
|
||||
/** When this type is the connection source, which target types are allowed. Omit = all. */
|
||||
allowedTargetTypes?: string[]
|
||||
help: NodeHelpEntry
|
||||
menuLabel: string
|
||||
menuIcon: React.ReactNode
|
||||
/** Optional: override default data for new nodes (e.g. set title from newId). */
|
||||
getDefaultData?: (newId?: string) => Record<string, unknown>
|
||||
/** Optional: data for Reset action; if omitted, getDefaultData(nodeId) or defaultData is used. */
|
||||
getResetData?: (nodeId?: string) => Record<string, unknown>
|
||||
/** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */
|
||||
connectionLabel?: string
|
||||
}
|
||||
|
||||
const registry = new Map<string, NodeTypeDescriptor>()
|
||||
|
||||
export function registerNodeType(descriptor: NodeTypeDescriptor): void {
|
||||
if (registry.has(descriptor.id)) {
|
||||
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
|
||||
}
|
||||
registry.set(descriptor.id, descriptor)
|
||||
}
|
||||
|
||||
export function getNodeType(id: string): NodeTypeDescriptor | undefined {
|
||||
return registry.get(id)
|
||||
}
|
||||
|
||||
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
|
||||
return Array.from(registry.values())
|
||||
}
|
||||
|
||||
export function getRegisteredNodeTypeIds(): string[] {
|
||||
return Array.from(registry.keys())
|
||||
}
|
||||
|
||||
/** Default data for a new node of this type. Uses getDefaultData from descriptor if provided. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return {}
|
||||
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
|
||||
const base = { ...desc.defaultData }
|
||||
if (type === 'config' && newId) (base as any).title = `${newId}`
|
||||
return base
|
||||
}
|
||||
|
||||
/** Data for Reset action. Uses getResetData from descriptor if provided. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return getDefaultDataForType(type, nodeId)
|
||||
if (desc.getResetData) return { ...desc.getResetData(nodeId) }
|
||||
return getDefaultDataForType(type, nodeId)
|
||||
}
|
||||
|
||||
/** Id prefix for this type (e.g. cfg_, rnd_). Used by getNextNodeId. */
|
||||
export function getIdPrefix(type: string): string {
|
||||
return getNodeType(type)?.idPrefix ?? 'node_'
|
||||
}
|
||||
|
||||
/** Default style for this type. */
|
||||
export function getDefaultStyle(type: string): { width: number; height: number } {
|
||||
const desc = getNodeType(type)
|
||||
if (desc) return desc.defaultStyle
|
||||
return { width: 320, height: 320 }
|
||||
}
|
||||
|
||||
/** Whether a connection from source to target is allowed based on registered types. */
|
||||
export function isConnectionAllowed(
|
||||
sourceType: string,
|
||||
targetType: string,
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string
|
||||
): boolean {
|
||||
if (sourceNodeId === targetNodeId) return false
|
||||
const sourceDesc = getNodeType(sourceType)
|
||||
const targetDesc = getNodeType(targetType)
|
||||
if (!sourceDesc || !targetDesc) return false
|
||||
if (!targetDesc.hasInput) return false
|
||||
if (!sourceDesc.hasOutput) return false
|
||||
if (targetDesc.allowedSourceTypes != null && !targetDesc.allowedSourceTypes.includes(sourceType)) return false
|
||||
if (sourceDesc.allowedTargetTypes != null && !sourceDesc.allowedTargetTypes.includes(targetType)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Edge label when target is of this type (for AnimatedEdge). */
|
||||
export function getConnectionLabelForTarget(targetType: string): string | undefined {
|
||||
return getNodeType(targetType)?.connectionLabel
|
||||
}
|
||||
|
||||
/** Help entry for a node type. Use in NodeHelpPopover. */
|
||||
export function getNodeHelp(nodeType: string): NodeHelpEntry {
|
||||
const desc = getNodeType(nodeType)
|
||||
return desc?.help ?? { title: nodeType, content: null }
|
||||
}
|
||||
13
frontend/src/lib/nodeTypes.ts
Normal file
13
frontend/src/lib/nodeTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
|
||||
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
||||
|
||||
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData
|
||||
export type AppNode = Node<AppNodeData>
|
||||
export type AppEdge = Edge
|
||||
92
frontend/src/lib/nunjucksAutocomplete.ts
Normal file
92
frontend/src/lib/nunjucksAutocomplete.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
|
||||
const NUNJUCKS_KEYWORDS = [
|
||||
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
|
||||
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
|
||||
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
|
||||
]
|
||||
|
||||
const NUNJUCKS_FILTERS = [
|
||||
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
|
||||
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
|
||||
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
|
||||
]
|
||||
|
||||
/** Get line text (CodeMirror 6: doc.line(n) is 1-based) */
|
||||
function getLineText(state: EditorState, lineNo0Based: number): string {
|
||||
return state.doc.line(lineNo0Based + 1).text
|
||||
}
|
||||
|
||||
/** Detect if position is inside {{ or {% from the start of the line */
|
||||
function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
const before = line.slice(0, posInLine)
|
||||
const openVar = before.lastIndexOf('{{')
|
||||
const openTag = before.lastIndexOf('{%')
|
||||
const closeVar = before.lastIndexOf('}}')
|
||||
const closeTag = before.lastIndexOf('%}')
|
||||
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
|
||||
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Get the word fragment before the cursor for matching */
|
||||
function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
let start = posInLine
|
||||
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
|
||||
return line.slice(start, posInLine)
|
||||
}
|
||||
|
||||
export function nunjucksCompletionSource(
|
||||
variableIds: string[],
|
||||
configTitles?: string[],
|
||||
functionIds?: string[],
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext) => {
|
||||
const { state, pos } = context
|
||||
const line = state.doc.lineAt(pos)
|
||||
if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null
|
||||
|
||||
const word = wordBefore(state, line.number - 1, pos - line.from)
|
||||
const from = pos - word.length
|
||||
|
||||
const options: { label: string; type?: string; info?: string }[] = []
|
||||
|
||||
for (const id of variableIds) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'variable', info: 'Variable' })
|
||||
}
|
||||
}
|
||||
for (const id of functionIds ?? []) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
|
||||
}
|
||||
}
|
||||
for (const kw of NUNJUCKS_KEYWORDS) {
|
||||
if (!word || kw.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
|
||||
}
|
||||
}
|
||||
for (const f of NUNJUCKS_FILTERS) {
|
||||
if (!word || f.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
|
||||
}
|
||||
}
|
||||
if (configTitles?.length) {
|
||||
for (const t of configTitles) {
|
||||
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: t, type: 'variable', info: 'Config' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
return {
|
||||
from,
|
||||
options: options.slice(0, 50),
|
||||
validFor: /^[\w.-]*$/,
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend/src/lib/plantumlLanguage.ts
Normal file
79
frontend/src/lib/plantumlLanguage.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
|
||||
/** Nunjucks block comment {# ... #} */
|
||||
function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
|
||||
if (stream.match(/^\{#/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/#\}/)) return 'comment'
|
||||
stream.next()
|
||||
}
|
||||
return 'comment'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
|
||||
function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
|
||||
if (stream.match(/^\{\{/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/\}\}/)) return 'variableName.special'
|
||||
stream.next()
|
||||
}
|
||||
return 'variableName.special'
|
||||
}
|
||||
if (stream.match(/^\{\%/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/%\}/)) return 'keyword'
|
||||
stream.next()
|
||||
}
|
||||
return 'keyword'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
|
||||
const plantumlParser = StreamLanguage.define({
|
||||
name: 'plantuml',
|
||||
token(stream) {
|
||||
// Nunjucks {# ... #} comment
|
||||
const nunjucksComment = tokenNunjucksComment(stream)
|
||||
if (nunjucksComment) return nunjucksComment
|
||||
// Nunjucks {{ }} and {% %}
|
||||
const nunjucksBlock = tokenNunjucksBlock(stream)
|
||||
if (nunjucksBlock) return nunjucksBlock
|
||||
|
||||
// Single-quote line comment (PlantUML)
|
||||
if (stream.match(/^'/)) {
|
||||
stream.skipToEnd()
|
||||
return 'comment'
|
||||
}
|
||||
// Double-quoted string
|
||||
if (stream.match(/^"/)) {
|
||||
let escaped = false
|
||||
while (!stream.eol()) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
stream.next()
|
||||
continue
|
||||
}
|
||||
const ch = stream.next()
|
||||
if (ch === '\\') escaped = true
|
||||
else if (ch === '"') break
|
||||
}
|
||||
return 'string'
|
||||
}
|
||||
// @directives (@startuml, @enduml, etc.)
|
||||
if (stream.match(/^@\w+/)) return 'meta'
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null
|
||||
// Arrows and connectors
|
||||
if (stream.match(/^->>?|<-<?|-->>?|<<--?|<-?>/)) return 'keyword'
|
||||
// Keywords (participant, actor, as, title, etc.)
|
||||
if (stream.match(/^(participant|actor|as|title|autonumber|left|right|of|over|activate|deactivate|destroy|create|group|opt|alt|else|loop|par|end|note|legend|skinparam|start|stop|if|endif|elseif|while|endwhile|repeat|until|switch|case|endswitch|class|interface|enum|package|namespace|abstract|static|extends|implements)\b/i)) return 'keyword'
|
||||
// Any other character (identifier, punctuation, etc.)
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const plantumlLanguage = plantumlParser
|
||||
93
frontend/src/lib/registerBuiltinNodes.tsx
Normal file
93
frontend/src/lib/registerBuiltinNodes.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Registers built-in node types (config, render, variable, function).
|
||||
* Import this once at app startup (e.g. in main.tsx) so the registry is populated.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ScrollText, Sparkles, Variable, Code2 } from 'lucide-react'
|
||||
import { registerNodeType } from './nodeRegistry'
|
||||
import { NODE_HELP } from './nodeHelp'
|
||||
import ConfigNode from '../components/nodes/ConfigNode'
|
||||
import RenderingNode from '../components/nodes/RenderingNode'
|
||||
import VariableNode from '../components/nodes/VariableNode'
|
||||
import FunctionNode from '../components/nodes/FunctionNode'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
export function registerBuiltinNodes(): void {
|
||||
registerNodeType({
|
||||
id: 'config',
|
||||
component: ConfigNode,
|
||||
defaultStyle: { width: 320, height: 320 },
|
||||
defaultData: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
|
||||
idPrefix: 'cfg_',
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedTargetTypes: ['config', 'render'],
|
||||
help: NODE_HELP.config,
|
||||
menuLabel: 'Config',
|
||||
menuIcon: <ScrollText className={ICON_CLASS} />,
|
||||
getDefaultData: (newId) => ({
|
||||
configType: 'plantuml',
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: newId ?? '',
|
||||
}),
|
||||
getResetData: (nodeId) => ({
|
||||
configType: 'plantuml',
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: nodeId ?? '',
|
||||
}),
|
||||
connectionLabel: 'adding input',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'render',
|
||||
component: RenderingNode,
|
||||
defaultStyle: { width: 384, height: 320 },
|
||||
defaultData: { viewportWidth: 1200, viewportHeight: 800 },
|
||||
idPrefix: 'rnd_',
|
||||
hasInput: true,
|
||||
hasOutput: false,
|
||||
allowedSourceTypes: ['config'],
|
||||
help: NODE_HELP.render,
|
||||
menuLabel: 'Renderer',
|
||||
menuIcon: <Sparkles className={ICON_CLASS} />,
|
||||
connectionLabel: 'rendering',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'variable',
|
||||
component: VariableNode,
|
||||
defaultStyle: { width: 224, height: 180 },
|
||||
defaultData: { value: '', valueType: 'string' },
|
||||
idPrefix: 'var_',
|
||||
hasInput: false,
|
||||
hasOutput: true,
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
help: NODE_HELP.variable,
|
||||
menuLabel: 'Variable',
|
||||
menuIcon: <Variable className={ICON_CLASS} />,
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'function',
|
||||
component: FunctionNode,
|
||||
defaultStyle: { width: 288, height: 260 },
|
||||
defaultData: {
|
||||
body: `function(num, kwargs) {
|
||||
return num + (kwargs.bar || 0);
|
||||
}`,
|
||||
},
|
||||
idPrefix: 'fn_',
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
help: NODE_HELP.function,
|
||||
menuLabel: 'Function',
|
||||
menuIcon: <Code2 className={ICON_CLASS} />,
|
||||
getResetData: () => ({ body: '' }),
|
||||
connectionLabel: 'adding input',
|
||||
})
|
||||
}
|
||||
83
frontend/src/lib/themeContext.tsx
Normal file
83
frontend/src/lib/themeContext.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'zui-theme'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
function readStored(): Theme | null {
|
||||
try {
|
||||
const s = localStorage.getItem(STORAGE_KEY)
|
||||
if (s === 'light' || s === 'dark') return s
|
||||
} catch (_) {}
|
||||
return null
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = readStored()
|
||||
if (stored) return stored
|
||||
return systemPrefersDark() ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const m = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
if (readStored() != null) return
|
||||
setThemeState(m.matches ? 'dark' : 'light')
|
||||
}
|
||||
m.addEventListener('change', handler)
|
||||
return () => m.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch (_) {}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const next = prev === 'light' ? 'dark' : 'light'
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch (_) {}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme])
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user