From 9ee7633bc153ed9ecbd9a5d693a86fa038f4d21f Mon Sep 17 00:00:00 2001 From: Shine1i Date: Thu, 26 Feb 2026 12:45:38 +0100 Subject: [PATCH] feat(recipe-studio): add sidebar drag-drop block creation + spawn sheet added blocks at viewport center --- .../recipe-studio/components/block-sheet.tsx | 47 ++++- .../recipe-studio/recipe-studio-page.tsx | 161 +++++++++++++++++- .../stores/helpers/node-updates.ts | 9 +- .../recipe-studio/stores/recipe-studio.ts | 105 +++++++++--- 4 files changed, 279 insertions(+), 43 deletions(-) diff --git a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx index d9e278c05d..281eccd810 100644 --- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx +++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx @@ -17,12 +17,18 @@ import { Upload01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { type ReactElement, useMemo, useState } from "react"; +import { + type DragEvent as ReactDragEvent, + type ReactElement, + useMemo, + useState, +} from "react"; import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "./recipe-floating-icon-button-class"; import type { LlmType, SamplerType } from "../types"; import { BLOCK_GROUPS, getBlocksForKind, + type BlockType, type SeedBlockType, } from "../blocks/registry"; @@ -62,6 +68,12 @@ type BlockSheetProps = { onImport: () => void; }; +export const RECIPE_BLOCK_DND_MIME = "application/x-recipe-studio-block"; +export type RecipeBlockDragPayload = { + kind: SheetKind; + type: BlockType; +}; + function getSheetTitle(sheetView: SheetView): string { if (sheetView === "root") { return "Add a block"; @@ -110,22 +122,30 @@ function BlockSheetButton({ description, onClick, isActive = false, + draggable = false, + onDragStart, + showChevron = true, }: { icon: typeof Database02Icon; title: string; description: string; onClick: () => void; isActive?: boolean; + draggable?: boolean; + onDragStart?: (event: ReactDragEvent) => void; + showChevron?: boolean; }): ReactElement { return ( ); } @@ -175,6 +197,16 @@ export function BlockSheet({ onOpenChange?.(nextOpen); }; + const buildDragStart = + (kind: SheetKind, type: BlockType) => + (event: ReactDragEvent) => { + const payload: RecipeBlockDragPayload = { kind, type }; + const serialized = JSON.stringify(payload); + event.dataTransfer.setData(RECIPE_BLOCK_DND_MIME, serialized); + event.dataTransfer.setData("text/plain", serialized); + event.dataTransfer.effectAllowed = "copy"; + }; + return (
{ setSheetOpen(false); if (item.kind === "sampler") { diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index 6ba2a3e23a..bdb71d0216 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -18,6 +18,7 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { + type DragEvent as ReactDragEvent, type ReactElement, useCallback, useEffect, @@ -28,7 +29,11 @@ import { import { useShallow } from "zustand/react/shallow"; import "@xyflow/react/dist/style.css"; import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/recipe-graph-aux-node"; -import { BlockSheet } from "./components/block-sheet"; +import { + BlockSheet, + RECIPE_BLOCK_DND_MIME, + type RecipeBlockDragPayload, +} from "./components/block-sheet"; import { LayoutControls } from "./components/controls/layout-controls"; import { ViewportControls } from "./components/controls/viewport-controls"; import { ExecutionsView } from "./components/executions/executions-view"; @@ -45,9 +50,12 @@ import { ProcessorsDialog } from "./dialogs/processors-dialog"; import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions"; import { useRecipeStudioStore } from "./stores/recipe-studio"; import type { + LlmType, RecipeNode as RecipeBuilderNode, RecipeNodeData, + SamplerType, } from "./types"; +import type { SeedBlockType } from "./blocks/registry"; import { deriveDisplayGraph } from "./utils/graph/derive-display-graph"; import { getFitNodeIdsIgnoringNotes } from "./utils/graph/fit-view"; import { buildRecipePayload } from "./utils/payload"; @@ -166,6 +174,7 @@ export function RecipeStudioPage({ const [sheetContainer, setSheetContainer] = useState( null, ); + const flowContainerRef = useRef(null); const [blockSheetOpen, setBlockSheetOpen] = useState(false); const [activeView, setActiveView] = useState("editor"); const [processorsOpen, setProcessorsOpen] = useState(false); @@ -259,6 +268,138 @@ export function RecipeStudioPage({ [baseEdgeIds, onEdgesChange], ); + const handleDragOver = useCallback((event: ReactDragEvent) => { + if ( + !event.dataTransfer.types.includes(RECIPE_BLOCK_DND_MIME) && + !event.dataTransfer.types.includes("text/plain") + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }, []); + + const handleDrop = useCallback( + (event: ReactDragEvent) => { + if (!reactFlowInstance) { + return; + } + const raw = + event.dataTransfer.getData(RECIPE_BLOCK_DND_MIME) || + event.dataTransfer.getData("text/plain"); + if (!raw) { + return; + } + let payload: RecipeBlockDragPayload | null = null; + try { + const parsed = JSON.parse(raw) as { + kind?: RecipeBlockDragPayload["kind"]; + type?: RecipeBlockDragPayload["type"]; + }; + if ( + parsed.kind && + parsed.type && + (parsed.kind === "sampler" || + parsed.kind === "seed" || + parsed.kind === "llm" || + parsed.kind === "expression" || + parsed.kind === "note") + ) { + payload = { + kind: parsed.kind, + type: parsed.type, + }; + } + } catch { + payload = null; + } + if (!payload) { + return; + } + event.preventDefault(); + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + if (payload.kind === "sampler") { + addSamplerNode(payload.type as SamplerType, position, false); + return; + } + if (payload.kind === "seed") { + addSeedNode(payload.type as SeedBlockType, position, false); + return; + } + if (payload.kind === "expression") { + addExpressionNode(position, false); + return; + } + if (payload.kind === "note") { + addMarkdownNoteNode(position, false); + return; + } + if (payload.type === "model_provider") { + addModelProviderNode(position, false); + return; + } + if (payload.type === "model_config") { + addModelConfigNode(position, false); + return; + } + addLlmNode(payload.type as LlmType, position, false); + }, + [ + addExpressionNode, + addLlmNode, + addMarkdownNoteNode, + addModelConfigNode, + addModelProviderNode, + addSamplerNode, + addSeedNode, + reactFlowInstance, + ], + ); + const getViewportCenterPosition = useCallback(() => { + if (!reactFlowInstance || !flowContainerRef.current) { + return undefined; + } + const rect = flowContainerRef.current.getBoundingClientRect(); + return reactFlowInstance.screenToFlowPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + }, [reactFlowInstance]); + const handleAddSamplerFromSheet = useCallback( + (type: SamplerType) => { + addSamplerNode(type, getViewportCenterPosition()); + }, + [addSamplerNode, getViewportCenterPosition], + ); + const handleAddSeedFromSheet = useCallback( + (type: SeedBlockType) => { + addSeedNode(type, getViewportCenterPosition()); + }, + [addSeedNode, getViewportCenterPosition], + ); + const handleAddLlmFromSheet = useCallback( + (type: LlmType) => { + addLlmNode(type, getViewportCenterPosition()); + }, + [addLlmNode, getViewportCenterPosition], + ); + const handleAddModelProviderFromSheet = useCallback(() => { + addModelProviderNode(getViewportCenterPosition()); + }, [addModelProviderNode, getViewportCenterPosition]); + const handleAddModelConfigFromSheet = useCallback(() => { + addModelConfigNode(getViewportCenterPosition()); + }, [addModelConfigNode, getViewportCenterPosition]); + const handleAddExpressionFromSheet = useCallback(() => { + addExpressionNode(getViewportCenterPosition()); + }, [addExpressionNode, getViewportCenterPosition]); + const handleAddMarkdownNoteFromSheet = useCallback(() => { + addMarkdownNoteNode(getViewportCenterPosition()); + }, [addMarkdownNoteNode, getViewportCenterPosition]); + const configList = useMemo(() => Object.values(configs), [configs]); const config = activeConfigId ? configs[activeConfigId] : null; const dialogOptions = useMemo( @@ -416,10 +557,12 @@ export function RecipeStudioPage({ void persistRecipe(); }} /> -
+
{activeView === "editor" ? ( , Edge> onInit={setReactFlowInstance} + onDragOver={handleDragOver} + onDrop={handleDrop} nodes={displayGraph.nodes} edges={displayGraph.edges} nodeTypes={NODE_TYPES} @@ -483,13 +626,13 @@ export function RecipeStudioPage({ onViewChange={setSheetView} open={blockSheetOpen} onOpenChange={setBlockSheetOpen} - onAddSampler={addSamplerNode} - onAddSeed={addSeedNode} - onAddLlm={addLlmNode} - onAddModelProvider={addModelProviderNode} - onAddModelConfig={addModelConfigNode} - onAddExpression={addExpressionNode} - onAddMarkdownNote={addMarkdownNoteNode} + onAddSampler={handleAddSamplerFromSheet} + onAddSeed={handleAddSeedFromSheet} + onAddLlm={handleAddLlmFromSheet} + onAddModelProvider={handleAddModelProviderFromSheet} + onAddModelConfig={handleAddModelConfigFromSheet} + onAddExpression={handleAddExpressionFromSheet} + onAddMarkdownNote={handleAddMarkdownNoteFromSheet} onOpenProcessors={openProcessorsFromSheet} copied={copied} onCopy={copyRecipe} diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts index 6d17986595..0473776d7d 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts @@ -1,3 +1,4 @@ +import type { XYPosition } from "@xyflow/react"; import { DEFAULT_NODE_WIDTH } from "../../constants"; import type { RecipeNode, @@ -40,11 +41,13 @@ export function buildNodeUpdate( state: NodeUpdateState, config: NodeConfig, layoutDirection: LayoutDirection, + position?: XYPosition, + openDialog = true, ): NodeUpdateResult { const node: RecipeNode = { id: config.id, type: "builder", - position: { x: 0, y: state.nextY }, + position: position ?? { x: 0, y: state.nextY }, data: nodeDataFromConfig(config, layoutDirection), style: { width: DEFAULT_NODE_WIDTH }, selected: true, @@ -54,9 +57,9 @@ export function buildNodeUpdate( configs: { ...state.configs, [config.id]: config }, nodes: [...state.nodes.map((item) => ({ ...item, selected: false })), node], nextId: state.nextId + 1, - nextY: state.nextY + 140, + nextY: position ? state.nextY : state.nextY + 140, activeConfigId: config.id, - dialogOpen: mode === "dialog", + dialogOpen: openDialog && mode === "dialog", }; } diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts index 2005643a82..cae9d42703 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -79,13 +79,21 @@ type RecipeStudioState = { setLayoutDirection: (direction: LayoutDirection) => void; applyLayout: () => void; setLlmAuxVisibility: (id: string, visible: boolean) => void; - addSamplerNode: (type: SamplerType) => void; - addSeedNode: (type: SeedBlockType) => void; - addLlmNode: (type: LlmType) => void; - addModelProviderNode: () => void; - addModelConfigNode: () => void; - addExpressionNode: () => void; - addMarkdownNoteNode: () => void; + addSamplerNode: ( + type: SamplerType, + position?: XYPosition, + openDialog?: boolean, + ) => void; + addSeedNode: ( + type: SeedBlockType, + position?: XYPosition, + openDialog?: boolean, + ) => void; + addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void; + addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void; + addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void; + addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void; + addMarkdownNoteNode: (position?: XYPosition, openDialog?: boolean) => void; updateConfig: (id: string, patch: Partial) => void; loadRecipe: (snapshot: RecipeSnapshot) => void; setAuxNodePosition: (id: string, position: XYPosition) => void; @@ -130,6 +138,8 @@ function buildAddedNodeState( state: RecipeStudioState, kind: BlockKind, type: BlockType, + position?: XYPosition, + openDialog = true, ): Partial | RecipeStudioState { const id = `n${state.nextId}`; const existing = Object.values(state.configs); @@ -138,7 +148,13 @@ function buildAddedNodeState( return state; } const config = definition.createConfig(id, existing); - return buildNodeUpdate(state, config, state.layoutDirection); + return buildNodeUpdate( + state, + config, + state.layoutDirection, + position, + openDialog, + ); } function getAddedNodeContext( @@ -316,15 +332,23 @@ export const useRecipeStudioStore = create((set, get) => ({ }, }; }), - addSamplerNode: (type) => - set((state) => buildAddedNodeState(state, "sampler", type)), - addSeedNode: (type) => + addSamplerNode: (type, position, openDialog = true) => + set((state) => + buildAddedNodeState(state, "sampler", type, position, openDialog), + ), + addSeedNode: (type, position, openDialog = true) => set((state) => { const existing = Object.values(state.configs).find( (config) => config.kind === "seed", ); if (!existing) { - return buildAddedNodeState(state, "seed", type); + return buildAddedNodeState( + state, + "seed", + type, + position, + openDialog, + ); } let nextSourceType: SeedSourceType = "hf"; if (type === "seed_local") { @@ -362,13 +386,22 @@ export const useRecipeStudioStore = create((set, get) => ({ state.layoutDirection, ), activeConfigId: existing.id, - dialogOpen: true, + dialogOpen: openDialog, }; }), - addLlmNode: (type) => set((state) => buildAddedNodeState(state, "llm", type)), - addModelProviderNode: () => + addLlmNode: (type, position, openDialog = true) => + set((state) => + buildAddedNodeState(state, "llm", type, position, openDialog), + ), + addModelProviderNode: (position, openDialog = true) => set((state) => { - const added = buildAddedNodeState(state, "llm", "model_provider"); + const added = buildAddedNodeState( + state, + "llm", + "model_provider", + position, + openDialog, + ); const context = getAddedNodeContext(added); if (!context) { return added; @@ -380,7 +413,7 @@ export const useRecipeStudioStore = create((set, get) => ({ config.kind === "model_config" && !config.provider.trim(), ); - if (unboundModelConfigs.length > 0) { + if (!position && unboundModelConfigs.length > 0) { nodes = placeNodeNear( nodes, context.newNodeId, @@ -401,9 +434,15 @@ export const useRecipeStudioStore = create((set, get) => ({ } return { ...added, nodes, edges, configs }; }), - addModelConfigNode: () => + addModelConfigNode: (position, openDialog = true) => set((state) => { - const added = buildAddedNodeState(state, "llm", "model_config"); + const added = buildAddedNodeState( + state, + "llm", + "model_config", + position, + openDialog, + ); const context = getAddedNodeContext(added); if (!context) { return added; @@ -416,7 +455,7 @@ export const useRecipeStudioStore = create((set, get) => ({ const unboundLlms = Object.values(configs).filter( (config) => config.kind === "llm" && !config.model_alias.trim(), ); - if (providers.length === 1) { + if (!position && providers.length === 1) { nodes = placeNodeNear( nodes, context.newNodeId, @@ -424,7 +463,7 @@ export const useRecipeStudioStore = create((set, get) => ({ state.layoutDirection, "after", ); - } else if (unboundLlms.length > 0) { + } else if (!position && unboundLlms.length > 0) { nodes = placeNodeNear( nodes, context.newNodeId, @@ -455,10 +494,26 @@ export const useRecipeStudioStore = create((set, get) => ({ } return { ...added, nodes, edges, configs }; }), - addExpressionNode: () => - set((state) => buildAddedNodeState(state, "expression", "expression")), - addMarkdownNoteNode: () => - set((state) => buildAddedNodeState(state, "note", "markdown_note")), + addExpressionNode: (position, openDialog = true) => + set((state) => + buildAddedNodeState( + state, + "expression", + "expression", + position, + openDialog, + ), + ), + addMarkdownNoteNode: (position, openDialog = true) => + set((state) => + buildAddedNodeState( + state, + "note", + "markdown_note", + position, + openDialog, + ), + ), loadRecipe: (snapshot) => set((state) => ({ configs: snapshot.configs,