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

View File

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

View File

@@ -20,17 +20,23 @@ type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = {
nodeId: string
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
/** 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) */
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
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
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 nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
@@ -108,30 +114,45 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
</MenubarItem>
</MenubarContent>
</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>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Insert
{insertMenuLabel}
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
<MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{editInputsContent}
</MenubarSubContent>
</MenubarSub>
{insertTagsContent != null && (
{inputsMenuContent == null && editInputsContent != null && (
<MenubarSub>
<MenubarSubTrigger className="text-xs">
{insertTagsLabel}
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{insertTagsContent}
{editInputsContent}
</MenubarSubContent>
</MenubarSub>
)}
{insertTagsContent != null &&
(insertContentDirect ? (
insertTagsContent
) : (
<MenubarSub>
<MenubarSubTrigger className="text-xs">
{insertTagsLabel}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{insertTagsContent}
</MenubarSubContent>
</MenubarSub>
))}
</MenubarContent>
</MenubarMenu>
)}

View File

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

View File

@@ -135,7 +135,9 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
</BaseNodeContent>
<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>
</BaseNode>
)

View File

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