added wireframes
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
getConfigContent,
|
||||
getConfigType,
|
||||
getConfigTypeId,
|
||||
getDefaultContentForConfigType,
|
||||
isGroup,
|
||||
type ConfigTypeId,
|
||||
} from '../../lib/configTypes'
|
||||
@@ -49,7 +49,7 @@ type Props = {
|
||||
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
|
||||
const configTypeId = getConfigTypeId(data)
|
||||
const configType = getConfigType(configTypeId)
|
||||
const content = getConfigContent(data) || getDefaultContentForConfigType(configTypeId)
|
||||
const content = getConfigContent(data)
|
||||
const { theme } = useTheme()
|
||||
const ctx = useContext(FlowContext)
|
||||
const setNodes = ctx?.setNodes
|
||||
@@ -98,9 +98,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
||||
data: {
|
||||
...n.data,
|
||||
configType: newTypeId,
|
||||
content:
|
||||
getConfigContent(n.data) ||
|
||||
getDefaultContentForConfigType(newTypeId),
|
||||
content: getConfigContent(n.data) ?? '',
|
||||
},
|
||||
}
|
||||
: 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],
|
||||
)
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
configType.language === 'plantuml' ? plantumlLanguage.extension : markdown(),
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
],
|
||||
[configType.language, variableIds, functionIds, configTitles],
|
||||
)
|
||||
]
|
||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||
|
||||
const insertBlocksContent = useMemo(() => {
|
||||
|
||||
@@ -31,7 +31,6 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
const runIdRef = useRef(0)
|
||||
const loadingStartedAtRef = useRef<number | null>(null)
|
||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
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).
|
||||
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
|
||||
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
|
||||
|
||||
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 isWireframeOutput = configTypeId === 'wireframe' && renderedContent && !isSvgOutput
|
||||
|
||||
return (
|
||||
<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} />}>
|
||||
@@ -586,14 +586,22 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : renderedContent ? (
|
||||
<div
|
||||
className={
|
||||
isSvgOutput
|
||||
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary [&_svg]:max-w-full [&_svg]:h-auto'
|
||||
: '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 }}
|
||||
/>
|
||||
isWireframeOutput ? (
|
||||
<div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
|
||||
<div className="rendering-wireframe__viewport">
|
||||
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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). */
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -26,7 +29,7 @@ export type ConfigType = {
|
||||
/** 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) => Promise<string>
|
||||
render: (content: string, options?: RenderOptions) => Promise<string>
|
||||
}
|
||||
|
||||
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. */
|
||||
async function renderMarkdown(content: string): Promise<string> {
|
||||
const { marked } = await import('marked')
|
||||
@@ -81,6 +108,17 @@ async function renderMarkdown(content: string): Promise<string> {
|
||||
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[] = [
|
||||
{
|
||||
id: 'plantuml',
|
||||
@@ -107,6 +145,13 @@ export const CONFIG_TYPES: ConfigType[] = [
|
||||
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)
|
||||
@@ -121,6 +166,15 @@ export function getConfigType(id: ConfigTypeId): ConfigType {
|
||||
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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +56,41 @@ body {
|
||||
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 {
|
||||
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 */
|
||||
.react-flow__edge path.animated-edge-path,
|
||||
.animated-edge-path {
|
||||
|
||||
Reference in New Issue
Block a user