feat(recipe-studio): add sidebar drag-drop block creation + spawn sheet added blocks at viewport center

This commit is contained in:
Shine1i 2026-02-26 12:45:38 +01:00
commit 9ee7633bc1
4 changed files with 279 additions and 43 deletions

View file

@ -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<HTMLButtonElement>) => void;
showChevron?: boolean;
}): ReactElement {
return (
<button
type="button"
onClick={onClick}
draggable={draggable}
onDragStart={onDragStart}
className={`flex w-full items-center gap-3 border-l-2 bg-background px-3 py-3 text-left transition hover:bg-muted/35 ${
isActive
? "border-emerald-500"
: "border-transparent hover:border-border/60"
}`}
} ${draggable ? "cursor-grab active:cursor-grabbing" : ""}`}
>
<div className="flex size-9 items-center justify-center rounded-xl text-foreground/70">
<HugeiconsIcon icon={icon} className="size-5" />
@ -134,10 +154,12 @@ function BlockSheetButton({
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
{showChevron ? (
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
) : null}
</button>
);
}
@ -175,6 +197,16 @@ export function BlockSheet({
onOpenChange?.(nextOpen);
};
const buildDragStart =
(kind: SheetKind, type: BlockType) =>
(event: ReactDragEvent<HTMLButtonElement>) => {
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 (
<div className="flex flex-col items-end gap-2">
<Sheet
@ -275,6 +307,9 @@ export function BlockSheet({
title={item.title}
description={item.description}
isActive={index === 0}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
showChevron={!(sheetView === "expression" || sheetView === "note")}
onClick={() => {
setSheetOpen(false);
if (item.kind === "sampler") {

View file

@ -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<HTMLDivElement | null>(
null,
);
const flowContainerRef = useRef<HTMLDivElement | null>(null);
const [blockSheetOpen, setBlockSheetOpen] = useState(false);
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
@ -259,6 +268,138 @@ export function RecipeStudioPage({
[baseEdgeIds, onEdgesChange],
);
const handleDragOver = useCallback((event: ReactDragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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();
}}
/>
<div className="h-[75vh] w-full rounded-t-none">
<div className="h-[75vh] w-full rounded-t-none" ref={flowContainerRef}>
{activeView === "editor" ? (
<ReactFlow<Node<RecipeNodeData | RecipeGraphAuxNodeData>, 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}

View file

@ -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",
};
}

View file

@ -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<NodeConfig>) => 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> | 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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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,