added wireframes

This commit is contained in:
2026-03-08 23:23:11 +01:00
parent 9ce0d55370
commit 33aa313f34
6 changed files with 127 additions and 24 deletions

7
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@uiw/react-codemirror": "^4.25.7", "@uiw/react-codemirror": "^4.25.7",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -3445,6 +3446,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@wireweave/core": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@wireweave/core/-/core-2.6.0.tgz",
"integrity": "sha512-Cov/aVX/+bjoduUJTBxXuDxYOiQuNnlbjkX+pyVL9azoT7RNKwHLnQ3IXfhxeQzG/nfP17Mbon0Vmd5gsoz1lg==",
"license": "MIT"
},
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.1", "version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@uiw/react-codemirror": "^4.25.7", "@uiw/react-codemirror": "^4.25.7",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -1,6 +1,7 @@
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react' import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete' import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils' import { nodePropsAreEqual } from '../../lib/flowUtils'
@@ -13,7 +14,6 @@ import {
getConfigContent, getConfigContent,
getConfigType, getConfigType,
getConfigTypeId, getConfigTypeId,
getDefaultContentForConfigType,
isGroup, isGroup,
type ConfigTypeId, type ConfigTypeId,
} from '../../lib/configTypes' } from '../../lib/configTypes'
@@ -49,7 +49,7 @@ type Props = {
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) { export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
const configTypeId = getConfigTypeId(data) const configTypeId = getConfigTypeId(data)
const configType = getConfigType(configTypeId) const configType = getConfigType(configTypeId)
const content = getConfigContent(data) || getDefaultContentForConfigType(configTypeId) const content = getConfigContent(data)
const { theme } = useTheme() const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
@@ -98,9 +98,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
data: { data: {
...n.data, ...n.data,
configType: newTypeId, configType: newTypeId,
content: content: getConfigContent(n.data) ?? '',
getConfigContent(n.data) ||
getDefaultContentForConfigType(newTypeId),
}, },
} }
: n : n
@@ -181,16 +179,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id), () => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
[connectedConfigNodes], [connectedConfigNodes],
) )
const extensions = useMemo( const extensions = useMemo(() => {
() => [ const lang =
configType.language === 'plantuml' ? plantumlLanguage.extension : markdown(), configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [
lang,
autocompletion({ autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)], override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
activateOnTyping: true, activateOnTyping: true,
}), }),
], ]
[configType.language, variableIds, functionIds, configTitles], }, [configTypeId, configType.language, variableIds, functionIds, configTitles])
)
const [editorHeight, editorContainerRef] = useResizeHeight(180) const [editorHeight, editorContainerRef] = useResizeHeight(180)
const insertBlocksContent = useMemo(() => { const insertBlocksContent = useMemo(() => {

View File

@@ -31,7 +31,6 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const runIdRef = useRef(0) const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null) const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? [] const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? [] const edges = ctx?.edges ?? []
@@ -474,7 +473,6 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
} }
} }
// Only re-run when inputs that affect the resolved output change (signatures + source). // Only re-run when inputs that affect the resolved output change (signatures + source).
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature]) }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
const dimensions = const dimensions =
@@ -519,6 +517,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial' const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
const isWireframeOutput = configTypeId === 'wireframe' && renderedContent && !isSvgOutput
return ( return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}> <NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}> <BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}>
@@ -586,14 +586,22 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
) : loading ? ( ) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div> <div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : renderedContent ? ( ) : renderedContent ? (
<div isWireframeOutput ? (
className={ <div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
isSvgOutput <div className="rendering-wireframe__viewport">
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary [&_svg]:max-w-full [&_svg]:h-auto' <div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
: 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded' </div>
} </div>
dangerouslySetInnerHTML={{ __html: renderedContent }} ) : (
/> <div
className={
isSvgOutput
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary'
: 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
}
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
)
) : null} ) : null}
</div> </div>
</BaseNodeContent> </BaseNodeContent>

View File

@@ -4,7 +4,7 @@
* Add a new entry here and wire its language in ConfigNode to add a new config type. * Add a new entry here and wire its language in ConfigNode to add a new config type.
*/ */
export type ConfigTypeId = 'plantuml' | 'markdown' export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe'
/** A single insert option (label + snippet to insert at cursor). */ /** A single insert option (label + snippet to insert at cursor). */
export type InsertBlock = { label: string; snippet: string } export type InsertBlock = { label: string; snippet: string }
@@ -18,6 +18,9 @@ function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
return 'items' in b && Array.isArray((b as InsertBlockGroup).items) 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 = { export type ConfigType = {
id: ConfigTypeId id: ConfigTypeId
label: string label: string
@@ -26,7 +29,7 @@ export type ConfigType = {
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */ /** Options under Insert → [type-specific]. Can be flat blocks or groups. */
insertBlocks: InsertBlockOrGroup[] insertBlocks: InsertBlockOrGroup[]
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */ /** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
render: (content: string) => Promise<string> render: (content: string, options?: RenderOptions) => Promise<string>
} }
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg' const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
@@ -74,6 +77,30 @@ const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
}, },
] ]
/** 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. */ /** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
async function renderMarkdown(content: string): Promise<string> { async function renderMarkdown(content: string): Promise<string> {
const { marked } = await import('marked') const { marked } = await import('marked')
@@ -81,6 +108,17 @@ async function renderMarkdown(content: string): Promise<string> {
return typeof html === 'string' ? html : String(html) return typeof html === 'string' ? html : String(html)
} }
/** Wireweave DSL to HTML+CSS; theme from document dark mode. See https://www.wireweave.org/ */
async function renderWireframe(content: string): Promise<string> {
const { parse, render } = await import('@wireweave/core')
const doc = parse(content)
const isDark =
typeof document !== 'undefined' &&
document.documentElement?.classList?.contains('dark')
const { html, css } = render(doc, { theme: isDark ? 'dark' : 'light' })
return `<style>${css}</style>${html}`
}
export const CONFIG_TYPES: ConfigType[] = [ export const CONFIG_TYPES: ConfigType[] = [
{ {
id: 'plantuml', id: 'plantuml',
@@ -107,6 +145,13 @@ export const CONFIG_TYPES: ConfigType[] = [
insertBlocks: MARKDOWN_INSERT_BLOCKS, insertBlocks: MARKDOWN_INSERT_BLOCKS,
render: renderMarkdown, 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 CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
@@ -121,6 +166,15 @@ export function getConfigType(id: ConfigTypeId): ConfigType {
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string { export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n' if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
if (configTypeId === 'markdown') return '' if (configTypeId === 'markdown') return ''
if (configTypeId === 'wireframe') {
return `page "Hello" {
card p=4 {
title "Welcome"
text "Hello, wireweave!"
button "Get Started" primary
}
}`
}
return '' return ''
} }

View File

@@ -56,11 +56,41 @@ body {
pointer-events: none; pointer-events: none;
} }
/* Rendering node: diagram is fetched once (light). In dark mode, invert so it stays readable without re-fetching. */ /* Rendering node: diagram/wireframe SVG must fit inside the content area. */
.rendering-diagram {
display: flex;
align-items: center;
justify-content: center;
}
.rendering-diagram svg {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.dark .rendering-diagram svg { .dark .rendering-diagram svg {
filter: invert(0.92) hue-rotate(180deg); filter: invert(0.92) hue-rotate(180deg);
} }
/* Wireframe: fit HTML preview in viewport (inspired by https://www.wireweave.org/ ). */
.rendering-wireframe__viewport {
container-type: inline-size;
container-name: wireframe;
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
}
.rendering-wireframe__content {
width: 1200px;
/* Scale to fit container width so content stays within the node */
zoom: calc(100cqw / 1200px);
min-height: min-content;
}
/* Animated React Flow edges: thicker stroke + path animation */ /* Animated React Flow edges: thicker stroke + path animation */
.react-flow__edge path.animated-edge-path, .react-flow__edge path.animated-edge-path,
.animated-edge-path { .animated-edge-path {