added wireframes
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isSvgOutput
|
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-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'
|
: '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 }}
|
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|||||||
@@ -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 ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user