feat(recipe-studio): add sidebar drag-drop block creation + spawn sheet added blocks at viewport center
This commit is contained in:
parent
04d6f5e67b
commit
9ee7633bc1
4 changed files with 279 additions and 43 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue