improved menus

This commit is contained in:
2026-03-08 22:27:59 +01:00
parent 4022975e4d
commit 74fe1bed33
6 changed files with 153 additions and 110 deletions

View File

@@ -26,10 +26,13 @@ import {
import { Code2, ScrollText, Variable } from 'lucide-react' import { Code2, ScrollText, Variable } from 'lucide-react'
import { import {
MenubarItem, MenubarItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub, MenubarSub,
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
} from '../ui/menubar' } from '../ui/menubar'
import { Kbd } from '../ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
@@ -191,31 +194,55 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const [editorHeight, editorContainerRef] = useResizeHeight(180) const [editorHeight, editorContainerRef] = useResizeHeight(180)
const insertBlocksContent = useMemo(() => { const insertBlocksContent = useMemo(() => {
return configType.insertBlocks.map((block, idx) => const templatingGroup = configType.insertBlocks.find(
isGroup(block) ? ( (b): b is import('../../lib/configTypes').InsertBlockGroup => isGroup(b) && b.label === 'Templating'
<MenubarSub key={`${block.label}-${idx}`}> )
<MenubarSubTrigger className="text-xs">{block.label}</MenubarSubTrigger> const typeBlocks = configType.insertBlocks.filter((b) => !(isGroup(b) && b.label === 'Templating'))
<MenubarSubContent className="min-w-[12rem]"> const insertShortcut = <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
{block.items.map(({ label, snippet }) => ( const typeItems = typeBlocks.flatMap((block) =>
<MenubarItem isGroup(block)
key={label} ? block.items.map(({ label, snippet }) => (
className="text-xs" <MenubarItem
onClick={() => insertAt(snippet, 'cursor')} key={label}
> className="text-xs flex items-center group"
{label} onClick={() => insertAt(snippet, 'cursor')}
</MenubarItem> >
))} {label}
</MenubarSubContent> {insertShortcut}
</MenubarSub> </MenubarItem>
) : ( ))
: [
<MenubarItem
key={block.label}
className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')}
>
{block.label}
{insertShortcut}
</MenubarItem>,
]
)
const templatingItems =
templatingGroup?.items.map(({ label, snippet }) => (
<MenubarItem <MenubarItem
key={block.label} key={label}
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')} onClick={() => insertAt(snippet, 'cursor')}
> >
{block.label} {label}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
) )) ?? []
return (
<>
{typeItems}
{templatingItems.length > 0 && (
<>
<MenubarSeparator />
{templatingItems}
</>
)}
</>
) )
}, [configType.insertBlocks, insertAt]) }, [configType.insertBlocks, insertAt])
@@ -250,7 +277,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<NodeMenubar <NodeMenubar
nodeId={id} nodeId={id}
nodeType="config" nodeType="config"
editInputsContent={ inputsMenuContent={
hasDependencies ? ( hasDependencies ? (
<> <>
{connectedConfigNodes.map((n: any) => ( {connectedConfigNodes.map((n: any) => (
@@ -261,63 +288,59 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</MenubarSubTrigger> </MenubarSubTrigger>
<MenubarSubContent> <MenubarSubContent>
<MenubarItem <MenubarItem
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertExtendsFromNode(n, 'cursor')} onClick={() => insertExtendsFromNode(n, 'cursor')}
> >
Extend Extend
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem <MenubarItem
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertIncludeFromNode(n, 'cursor')} onClick={() => insertIncludeFromNode(n, 'cursor')}
> >
Include Include
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem <MenubarItem
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertImportFromNode(n, 'cursor')} onClick={() => insertImportFromNode(n, 'cursor')}
> >
Import Import
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
))} ))}
{connectedVariableNodes.map((n: any) => ( {connectedVariableNodes.map((n: any) => (
<MenubarSub key={`${n.id}`}> <MenubarItem
<MenubarSubTrigger className="text-xs flex items-center gap-2"> key={n.id}
<Variable className="size-3.5 shrink-0" /> className="text-xs flex items-center gap-2 group"
{n.id} onClick={() => insertVariableReference(n, 'cursor')}
</MenubarSubTrigger> >
<MenubarSubContent> <Variable className="size-3.5 shrink-0" />
<MenubarItem {n.id}
className="text-xs" <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
onClick={() => insertVariableReference(n, 'cursor')} </MenubarItem>
>
Insert
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
{connectedFunctionNodes.map((n: any) => ( {connectedFunctionNodes.map((n: any) => (
<MenubarSub key={`${n.id}`}> <MenubarItem
<MenubarSubTrigger className="text-xs flex items-center gap-2"> key={n.id}
<Code2 className="size-3.5 shrink-0" /> className="text-xs flex items-center gap-2 group"
{n.id} onClick={() => insertFunctionCall(n, 'cursor')}
</MenubarSubTrigger> >
<MenubarSubContent> <Code2 className="size-3.5 shrink-0" />
<MenubarItem {n.id}
className="text-xs" <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
onClick={() => insertFunctionCall(n, 'cursor')} </MenubarItem>
>
Insert
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
</> </>
) : undefined ) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
)
} }
insertMenuLabel="Blocks"
insertContentDirect
insertTagsContent={insertBlocksContent} insertTagsContent={insertBlocksContent}
insertTagsLabel={configType.label}
/> />
</div> </div>
@@ -338,7 +361,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="config"> <NodeFooterEdgeIndicators nodeId={id} nodeType="config">
{content ? `${content.length} chars` : 'none'} {configType.label} · {content ? `${content.length} chars` : 'none'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -15,7 +15,8 @@ import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarShortcut } from '../ui/menubar'
import { Kbd } from '../ui/kbd'
import { Code2, Variable } from 'lucide-react' import { Code2, Variable } from 'lucide-react'
type Props = { type Props = {
@@ -110,43 +111,35 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
<NodeMenubar <NodeMenubar
nodeId={id} nodeId={id}
nodeType="function" nodeType="function"
editInputsContent={ inputsMenuContent={
hasConnectedInputs ? ( hasConnectedInputs ? (
<> <>
{connectedVariableNodes.map((n: any) => ( {connectedVariableNodes.map((n: any) => (
<MenubarSub key={n.id}> <MenubarItem
<MenubarSubTrigger className="text-xs flex items-center gap-2"> key={n.id}
<Variable className="size-3.5 shrink-0" /> className="text-xs flex items-center gap-2 group"
{n.id} onClick={() => insertVariableAtCursor(n)}
</MenubarSubTrigger> >
<MenubarSubContent> <Variable className="size-3.5 shrink-0" />
<MenubarItem {n.id}
className="text-xs" <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
onClick={() => insertVariableAtCursor(n)} </MenubarItem>
>
Insert at cursor
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
{connectedFunctionNodes.map((n: any) => ( {connectedFunctionNodes.map((n: any) => (
<MenubarSub key={n.id}> <MenubarItem
<MenubarSubTrigger className="text-xs flex items-center gap-2"> key={n.id}
<Code2 className="size-3.5 shrink-0" /> className="text-xs flex items-center gap-2 group"
{n.id} onClick={() => insertFunctionAtCursor(n)}
</MenubarSubTrigger> >
<MenubarSubContent> <Code2 className="size-3.5 shrink-0" />
<MenubarItem {n.id}
className="text-xs" <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
onClick={() => insertFunctionAtCursor(n)} </MenubarItem>
>
Insert at cursor
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
</> </>
) : undefined ) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert at cursor</span>
)
} }
/> />
</div> </div>
@@ -167,7 +160,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="function"> <NodeFooterEdgeIndicators nodeId={id} nodeType="function">
{bodyValue ? `Javascript | ${bodyValue.length} chars` : 'none'} {bodyValue ? `JavaScript · ${bodyValue.length} chars` : 'JavaScript · none'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -20,17 +20,23 @@ type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = { type Props = {
nodeId: string nodeId: string
nodeType: NodeType nodeType: NodeType
/** Content for Insert → Inputs (config and function nodes only) */ /** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
editInputsContent?: React.ReactNode editInputsContent?: React.ReactNode
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
inputsMenuContent?: React.ReactNode
/** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */ /** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */
insertTagsContent?: React.ReactNode insertTagsContent?: React.ReactNode
/** Label for Insert submenu that shows insertTagsContent (default "Tags") */ /** Label for the Insert/Blocks menu trigger (default "Insert") */
insertMenuLabel?: string
/** When true, render insertTagsContent directly in the menu (no submenu level). Use for flat Blocks list. */
insertContentDirect?: boolean
/** Label for Insert submenu that shows insertTagsContent when not insertContentDirect (default "Tags") */
insertTagsLabel?: string insertTagsLabel?: string
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */ /** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
nodeMenuExtraContent?: React.ReactNode nodeMenuExtraContent?: React.ReactNode
} }
export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent, insertTagsLabel = 'Tags', nodeMenuExtraContent }: Props) { export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent }: Props) {
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? [] const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
@@ -108,30 +114,45 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
</MenubarItem> </MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
{hasEdit && ( {inputsMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
{inputsMenuContent}
</MenubarContent>
</MenubarMenu>
)}
{hasEdit && (insertTagsContent != null || (editInputsContent != null && inputsMenuContent == null)) && (
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs"> <MenubarTrigger className="px-1.5 py-0 text-xs">
Insert {insertMenuLabel}
</MenubarTrigger> </MenubarTrigger>
<MenubarContent className="min-w-[10rem]"> <MenubarContent className="min-w-[10rem]">
<MenubarSub> {inputsMenuContent == null && editInputsContent != null && (
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{editInputsContent}
</MenubarSubContent>
</MenubarSub>
{insertTagsContent != null && (
<MenubarSub> <MenubarSub>
<MenubarSubTrigger className="text-xs"> <MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
{insertTagsLabel} Inputs
</MenubarSubTrigger> </MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]"> <MenubarSubContent className="min-w-[12rem]">
{insertTagsContent} {editInputsContent}
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
)} )}
{insertTagsContent != null &&
(insertContentDirect ? (
insertTagsContent
) : (
<MenubarSub>
<MenubarSubTrigger className="text-xs">
{insertTagsLabel}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{insertTagsContent}
</MenubarSubContent>
</MenubarSub>
))}
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
)} )}

View File

@@ -599,7 +599,11 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render"> <NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{renderedContent ? (isSvgOutput ? 'Diagram' : 'Markdown') : error ? 'Error' : '—'} {renderedContent
? `${isSvgOutput ? 'Diagram' : 'Markdown'} · ${renderedContent.length} chars`
: error
? 'Error'
: '—'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -135,7 +135,9 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="variable" /> <NodeFooterEdgeIndicators nodeId={id} nodeType="variable">
{`${valueType.charAt(0).toUpperCase() + valueType.slice(1)} · ${displayValue.length ? `${displayValue.length} chars` : 'none'}`}
</NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -38,7 +38,7 @@ const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Arrow', snippet: ' -> ' }, { label: 'Arrow', snippet: ' -> ' },
{ label: 'Note', snippet: 'note right of ' }, { label: 'Note', snippet: 'note right of ' },
{ {
label: 'Nunjucks', label: 'Templating',
items: [ items: [
{ label: 'Variable {{ }}', snippet: '{{ }}' }, { label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' }, { label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
@@ -64,7 +64,7 @@ const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'List item', snippet: '- ' }, { label: 'List item', snippet: '- ' },
{ label: 'Blockquote', snippet: '> ' }, { label: 'Blockquote', snippet: '> ' },
{ {
label: 'Nunjucks', label: 'Templating',
items: [ items: [
{ label: 'Variable {{ }}', snippet: '{{ }}' }, { label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' }, { label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },