From 33aa313f3496545427f05da751e73dc596cd2a82 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 8 Mar 2026 23:23:11 +0100 Subject: [PATCH] added wireframes --- package-lock.json | 7 ++++ package.json | 1 + src/components/graph/ConfigNode.tsx | 25 ++++++----- src/components/graph/RenderingNode.tsx | 28 ++++++++----- src/lib/configTypes.ts | 58 +++++++++++++++++++++++++- src/styles.css | 32 +++++++++++++- 6 files changed, 127 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index d5a241f..a2b5f76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@uiw/react-codemirror": "^4.25.7", + "@wireweave/core": "^2.6.0", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -3445,6 +3446,12 @@ "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": { "version": "12.10.1", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", diff --git a/package.json b/package.json index d7078b3..73f26ba 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@uiw/react-codemirror": "^4.25.7", + "@wireweave/core": "^2.6.0", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx index cbee766..9a2cf38 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/graph/ConfigNode.tsx @@ -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(() => { diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index 6ca0714..3371a4b 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -31,7 +31,6 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | 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 ( }> @@ -586,14 +586,22 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: ) : loading ? (
Rendering…
) : renderedContent ? ( -
+ isWireframeOutput ? ( +
+
+
+
+
+ ) : ( +
+ ) ) : null}
diff --git a/src/lib/configTypes.ts b/src/lib/configTypes.ts index 03f68ff..ade3c35 100644 --- a/src/lib/configTypes.ts +++ b/src/lib/configTypes.ts @@ -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 + render: (content: string, options?: RenderOptions) => Promise } 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 { const { marked } = await import('marked') @@ -81,6 +108,17 @@ async function renderMarkdown(content: string): Promise { 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 { + 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 `${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 '' } diff --git a/src/styles.css b/src/styles.css index 25f5c9d..e422445 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 {