feat: add quick template functionality to NodePalette and enhance node creation experience

This commit is contained in:
catlog22
2026-02-05 09:57:20 +08:00
parent 0664937b98
commit a19ef94444
9 changed files with 293 additions and 38 deletions

View File

@@ -42,6 +42,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
const setNodes = useFlowStore((state) => state.setNodes);
const setEdges = useFlowStore((state) => state.setEdges);
const addNode = useFlowStore((state) => state.addNode);
const addNodeFromTemplate = useFlowStore((state) => state.addNodeFromTemplate);
const setSelectedNodeId = useFlowStore((state) => state.setSelectedNodeId);
const setSelectedEdgeId = useFlowStore((state) => state.setSelectedEdgeId);
const markModified = useFlowStore((state) => state.markModified);
@@ -127,10 +128,17 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
y: event.clientY,
});
// Add prompt-template node at drop position
addNode(position);
// Check if a template ID is provided
const templateId = event.dataTransfer.getData('application/reactflow-template-id');
if (templateId) {
// Use quick template
addNodeFromTemplate(templateId, position);
} else {
// Use basic empty node
addNode(position);
}
},
[screenToFlowPosition, addNode]
[screenToFlowPosition, addNode, addNodeFromTemplate]
);
return (

View File

@@ -1,44 +1,113 @@
// ========================================
// Node Palette Component
// ========================================
// Draggable node palette for creating new nodes
// Draggable node palette with quick templates for creating nodes
import { DragEvent, useState } from 'react';
import { useIntl } from 'react-intl';
import { MessageSquare, ChevronDown, ChevronRight, GripVertical } from 'lucide-react';
import {
MessageSquare, ChevronDown, ChevronRight, GripVertical,
Search, Code, FileOutput, GitBranch, GitFork, GitMerge, Plus, Terminal
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { useFlowStore } from '@/stores';
import { NODE_TYPE_CONFIGS } from '@/types/flow';
import { NODE_TYPE_CONFIGS, QUICK_TEMPLATES } from '@/types/flow';
interface NodePaletteProps {
className?: string;
}
/**
* Draggable card for the unified Prompt Template node type
* Icon mapping for quick templates
*/
function PromptTemplateCard() {
const config = NODE_TYPE_CONFIGS['prompt-template'];
const TEMPLATE_ICONS: Record<string, React.ElementType> = {
'slash-command-main': Terminal,
'slash-command-async': Terminal,
analysis: Search,
implementation: Code,
'file-operation': FileOutput,
conditional: GitBranch,
parallel: GitFork,
merge: GitMerge,
};
// Handle drag start
/**
* Draggable card for a quick template
*/
function QuickTemplateCard({
template,
}: {
template: typeof QUICK_TEMPLATES[number];
}) {
const Icon = TEMPLATE_ICONS[template.id] || MessageSquare;
// Handle drag start - store template ID
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
event.dataTransfer.setData('application/reactflow-template-id', template.id);
event.dataTransfer.effectAllowed = 'move';
};
// Handle double-click to add node at default position
const onDoubleClick = () => {
const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 };
useFlowStore.getState().addNodeFromTemplate(template.id, position);
};
return (
<div
draggable
onDragStart={onDragStart}
onDoubleClick={onDoubleClick}
className={cn(
'group flex items-center gap-3 p-3 rounded-lg border-2 bg-card cursor-grab transition-all',
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
`border-${template.color.replace('bg-', '')}`
)}
>
<div className={cn('p-2 rounded-md text-white', template.color, `hover:${template.color}`)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{template.label}</div>
<div className="text-xs text-muted-foreground truncate">{template.description}</div>
</div>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
);
}
/**
* Basic empty prompt template card
*/
function BasicTemplateCard() {
const config = NODE_TYPE_CONFIGS['prompt-template'];
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
event.dataTransfer.effectAllowed = 'move';
};
const onDoubleClick = () => {
const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 };
useFlowStore.getState().addNode(position);
};
return (
<div
draggable
onDragStart={onDragStart}
onDoubleClick={onDoubleClick}
className={cn(
'group flex items-center gap-3 p-3 rounded-lg border-2 bg-card cursor-grab transition-all',
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
'border-dashed border-muted-foreground/50 hover:border-primary',
'border-blue-500'
)}
>
<div className="p-2 rounded-md text-white bg-blue-500 hover:bg-blue-600">
<MessageSquare className="w-4 h-4" />
<Plus className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{config.label}</div>
@@ -49,9 +118,41 @@ function PromptTemplateCard() {
);
}
/**
* Category section with expand/collapse
*/
function TemplateCategory({
title,
children,
defaultExpanded = true,
}: {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
{title}
</button>
{isExpanded && <div className="space-y-2">{children}</div>}
</div>
);
}
export function NodePalette({ className }: NodePaletteProps) {
const { formatMessage } = useIntl();
const [isExpanded, setIsExpanded] = useState(true);
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
@@ -91,34 +192,39 @@ export function NodePalette({ className }: NodePaletteProps) {
{formatMessage({ id: 'orchestrator.palette.instructions' })}
</div>
{/* Node Type Categories */}
{/* Template Categories */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Execution Nodes */}
<div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
{formatMessage({ id: 'orchestrator.palette.nodeTypes' })}
</button>
{/* Basic / Empty Template */}
<TemplateCategory title="Basic" defaultExpanded={false}>
<BasicTemplateCard />
</TemplateCategory>
{isExpanded && (
<div className="space-y-2">
<PromptTemplateCard />
</div>
)}
</div>
{/* Slash Commands */}
<TemplateCategory title="Slash Commands" defaultExpanded={true}>
{QUICK_TEMPLATES.filter(t => t.id.startsWith('slash-command')).map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
{/* CLI Tools */}
<TemplateCategory title="CLI Tools" defaultExpanded={true}>
{QUICK_TEMPLATES.filter(t => ['analysis', 'implementation'].includes(t.id)).map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
{/* Flow Control */}
<TemplateCategory title="Flow Control" defaultExpanded={true}>
{QUICK_TEMPLATES.filter(t => ['file-operation', 'conditional', 'parallel', 'merge'].includes(t.id)).map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-border bg-muted/30">
<div className="text-xs text-muted-foreground">
<span className="font-medium">{formatMessage({ id: 'orchestrator.palette.tipLabel' })}</span> {formatMessage({ id: 'orchestrator.palette.tip' })}
<span className="font-medium">Tip:</span> Drag to canvas or double-click to add
</div>
</div>
</div>