mirror of
https://github.com/Stability-AI/StableStudio.git
synced 2026-05-16 23:15:56 +08:00
workflows
This commit is contained in:
7
packages/stablestudio-ui/public/file-code.svg
Normal file
7
packages/stablestudio-ui/public/file-code.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" fill="#3D3D3D"/>
|
||||
<path d="M5 21H17.25C17.7141 21 18.1592 20.8104 18.4874 20.4728C18.8156 20.1352 19 19.6774 19 19.2V7.95L14.1875 3H6.75C6.28587 3 5.84075 3.18964 5.51256 3.52721C5.18437 3.86477 5 4.32261 5 4.8V8.4" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 3V8H19" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 17L12 14.5L9 12" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 12L3 14.5L6 17" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 757 B |
@@ -18,6 +18,7 @@ use tauri_plugin_upload;
|
||||
mod server;
|
||||
mod settings;
|
||||
mod show_path;
|
||||
mod workflows;
|
||||
|
||||
static WINDOW: OnceLock<Window> = OnceLock::new();
|
||||
|
||||
@@ -84,6 +85,9 @@ fn main() {
|
||||
show_path::show_in_folder,
|
||||
settings::get_setting,
|
||||
settings::set_setting,
|
||||
workflows::fetch_workflows,
|
||||
workflows::save_workflow,
|
||||
workflows::delete_workflow,
|
||||
])
|
||||
.build(context)
|
||||
.expect("error while building tauri application")
|
||||
|
||||
94
packages/stablestudio-ui/src-tauri/src/workflows.rs
Normal file
94
packages/stablestudio-ui/src-tauri/src/workflows.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufReader, BufWriter},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Workflow {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub serialized_workflow: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn fetch_workflows(handle: tauri::AppHandle) -> Result<Vec<Workflow>, String> {
|
||||
let mut workflows: Vec<Workflow> = Vec::new();
|
||||
let mut path = handle.path_resolver().app_data_dir().unwrap();
|
||||
path.push("workflows");
|
||||
|
||||
// create directory if it doesn't exist
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
|
||||
for entry in fs::read_dir(path).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
let metadata = fs::metadata(&path).unwrap();
|
||||
if metadata.is_file() {
|
||||
let workflow_file = fs::File::open(path).unwrap();
|
||||
let reader = BufReader::new(workflow_file);
|
||||
if let Ok(workflow_data) = serde_json::from_reader(reader) {
|
||||
workflows.push(workflow_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(workflows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_workflow(
|
||||
handle: tauri::AppHandle,
|
||||
serialized_workflow: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
icon: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut workflow_path = handle.path_resolver().app_data_dir().unwrap();
|
||||
workflow_path.push("workflows");
|
||||
|
||||
// create directory if it doesn't exist
|
||||
std::fs::create_dir_all(&workflow_path).unwrap();
|
||||
|
||||
let file_name = name.replace(" ", "_").to_lowercase();
|
||||
workflow_path.push(format!("{file_name}.json"));
|
||||
|
||||
let workflow_file = fs::File::create(workflow_path).unwrap();
|
||||
let writer = BufWriter::new(workflow_file);
|
||||
|
||||
match serde_json::to_writer(
|
||||
writer,
|
||||
&Workflow {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
serialized_workflow,
|
||||
created_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
.to_string(),
|
||||
},
|
||||
) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_workflow(handle: tauri::AppHandle, name: String) -> Result<(), String> {
|
||||
let mut workflow_path = handle.path_resolver().app_data_dir().unwrap();
|
||||
workflow_path.push("workflows");
|
||||
|
||||
// create directory if it doesn't exist
|
||||
std::fs::create_dir_all(&workflow_path).unwrap();
|
||||
|
||||
workflow_path.push(format!("{name}.json"));
|
||||
|
||||
match fs::remove_file(workflow_path) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as StableStudio from "@stability/stablestudio-plugin";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { create } from "zustand";
|
||||
import { shallow } from "zustand/shallow";
|
||||
@@ -7,7 +6,7 @@ import { Generation } from "~/Generation";
|
||||
export type ComfyApp = {
|
||||
setup: () => void;
|
||||
registerNodes: () => void;
|
||||
loadGraphData: (graph: Graph) => void;
|
||||
loadGraphData: (graph?: Graph) => void;
|
||||
graphToPrompt: () => Promise<{
|
||||
workflow: any;
|
||||
prompt: any;
|
||||
@@ -18,6 +17,7 @@ export type ComfyApp = {
|
||||
clean: () => void;
|
||||
api: ComfyAPI;
|
||||
graph: {
|
||||
serialize: () => any;
|
||||
_nodes: {
|
||||
title: string;
|
||||
type: string;
|
||||
|
||||
@@ -77,13 +77,12 @@ export namespace Sidebar {
|
||||
const areStylesEnabled = Generation.Image.Styles.useAreEnabled();
|
||||
return (
|
||||
<>
|
||||
{areStylesEnabled && (
|
||||
<App.Sidebar.Section divider defaultExpanded padding="sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Generation.Image.Style.Dropdown id={id} />
|
||||
</div>
|
||||
</App.Sidebar.Section>
|
||||
)}
|
||||
<App.Sidebar.Section divider defaultExpanded padding="sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
{areStylesEnabled && <Generation.Image.Style.Dropdown id={id} />}
|
||||
<Generation.Image.Workflow.Dropdown />
|
||||
</div>
|
||||
</App.Sidebar.Section>
|
||||
<Generation.Image.Prompt.Sidebar.Section id={id} />
|
||||
{variant === "generate" && (
|
||||
<Generation.Image.Input.Image.Sidebar.Section id={id} />
|
||||
|
||||
128
packages/stablestudio-ui/src/Generation/Image/Workflow/Modal.tsx
Normal file
128
packages/stablestudio-ui/src/Generation/Image/Workflow/Modal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Generation } from "~/Generation";
|
||||
import { GlobalState } from "~/GlobalState";
|
||||
import { Theme } from "~/Theme";
|
||||
|
||||
export function Modal() {
|
||||
const {
|
||||
image,
|
||||
open,
|
||||
name,
|
||||
description,
|
||||
setImage,
|
||||
setName,
|
||||
setOpen,
|
||||
setDescription,
|
||||
} = Modal.State.use();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
return (
|
||||
<Theme.Modal
|
||||
modalName="Download"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<Theme.Modal.Panel className="flex w-[25rem] grow">
|
||||
<Theme.Modal.TopBar onClose={() => setOpen(false)}>
|
||||
<Theme.Modal.Title className="text-lg">
|
||||
Save workflow
|
||||
</Theme.Modal.Title>
|
||||
</Theme.Modal.TopBar>
|
||||
<div className="flex flex-col gap-3 p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Theme.Label className="mb-0 ml-0">Name</Theme.Label>
|
||||
<Theme.Input
|
||||
fullWidth
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Theme.Label className="mb-0 ml-0">Description</Theme.Label>
|
||||
<Theme.Input
|
||||
fullWidth
|
||||
autoSize
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Theme.Label className="mb-0 ml-0">Icon</Theme.Label>
|
||||
<input
|
||||
type={"file"}
|
||||
accept={"image/*"}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result !== "string") return;
|
||||
|
||||
setImage(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Theme.Button
|
||||
fullWidth
|
||||
size="lg"
|
||||
color="brand"
|
||||
icon={Theme.Icon.Download}
|
||||
loading={saving}
|
||||
onClick={async () => {
|
||||
if (!image || !name) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await Generation.Image.Workflow.saveWorkflow(
|
||||
name,
|
||||
image,
|
||||
description
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Theme.Button>
|
||||
</div>
|
||||
</Theme.Modal.Panel>
|
||||
</Theme.Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export namespace Modal {
|
||||
export type State = {
|
||||
image?: string | null;
|
||||
name: string;
|
||||
open: boolean;
|
||||
description: string;
|
||||
|
||||
setImage: (image?: string) => void;
|
||||
setName: (name: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
setDescription: (description: string) => void;
|
||||
};
|
||||
|
||||
export namespace State {
|
||||
export const use = GlobalState.create<State>((set) => ({
|
||||
image: null,
|
||||
name: "",
|
||||
open: false,
|
||||
description: "",
|
||||
|
||||
setImage: (image) => set({ image }),
|
||||
setName: (name) => set({ name }),
|
||||
setOpen: (open) => set({ open, name: "", image: null, description: "" }),
|
||||
setDescription: (description) => set({ description }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
188
packages/stablestudio-ui/src/Generation/Image/Workflow/index.tsx
Normal file
188
packages/stablestudio-ui/src/Generation/Image/Workflow/index.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { create } from "zustand";
|
||||
import { Comfy } from "~/Comfy";
|
||||
import { Theme } from "~/Theme";
|
||||
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export type Workflow = {
|
||||
name: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
serialized_workflow: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export declare namespace Workflow {
|
||||
export { Modal };
|
||||
}
|
||||
|
||||
export namespace Workflow {
|
||||
export const saveWorkflow = async (
|
||||
name: string,
|
||||
icon: string,
|
||||
description: string
|
||||
) => {
|
||||
const comfyApp = Comfy.get();
|
||||
if (!comfyApp) return;
|
||||
|
||||
const serializedWorkflow = JSON.stringify(comfyApp.graph.serialize(), null);
|
||||
try {
|
||||
await invoke("save_workflow", {
|
||||
name,
|
||||
icon,
|
||||
serializedWorkflow,
|
||||
description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
console.log("saved! reloading workflows");
|
||||
|
||||
try {
|
||||
Workflow.fetchWorkflows();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
function SlightBox({
|
||||
onClick,
|
||||
name,
|
||||
icon,
|
||||
}: {
|
||||
onClick: (e: any) => void;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
key="save"
|
||||
onClick={onClick}
|
||||
className={classes(
|
||||
"group flex cursor-pointer flex-col rounded duration-100"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 aspect-square min-h-0 w-full min-w-0 rounded-lg border border-dashed border-transparent border-zinc-600 duration-100 group-hover:border-solid group-hover:border-zinc-400">
|
||||
{icon}
|
||||
</div>
|
||||
<h1
|
||||
className={classes(
|
||||
"w-full grow select-none text-zinc-400 group-hover:text-zinc-200"
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const fetchWorkflows = async () => {
|
||||
const response = await invoke("fetch_workflows");
|
||||
Workflow.use.getState().setWorkflows(response as Workflow[]);
|
||||
};
|
||||
|
||||
export const applyWorkflow = async (workflowId: string) => {
|
||||
const workflow = use
|
||||
.getState()
|
||||
.workflows.find((workflow) => workflow.name === workflowId);
|
||||
|
||||
if (!workflow) return;
|
||||
|
||||
const comfyApp = Comfy.get();
|
||||
if (!comfyApp) return;
|
||||
|
||||
// turn serialized_workflow string into a file
|
||||
const blob = new Blob([workflow.serialized_workflow], {
|
||||
type: "text/json",
|
||||
});
|
||||
const file = new File([blob], "workflow.json", { type: "text/json" });
|
||||
|
||||
// load the file into comfy
|
||||
comfyApp.handleFile(file);
|
||||
};
|
||||
|
||||
export const use = create<{
|
||||
workflows: Workflow[];
|
||||
setWorkflows: (workflows: Workflow[]) => void;
|
||||
}>((set) => ({
|
||||
workflows: [],
|
||||
setWorkflows: (workflows) => set({ workflows }),
|
||||
}));
|
||||
|
||||
export function Dropdown({ className }: Styleable) {
|
||||
const { workflows, setWorkflows } = Workflow.use();
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflows();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: " save",
|
||||
image: "/save.svg",
|
||||
component: (onClick: any) => (
|
||||
<SlightBox
|
||||
onClick={(e) => {
|
||||
Modal.State.use.getState().setOpen(true);
|
||||
onClick(e);
|
||||
}}
|
||||
name="Save workflow"
|
||||
icon={<Theme.Icon.Save color="#7F7F7F" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
...(workflows ?? [])
|
||||
.sort((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? -1 : 1
|
||||
)
|
||||
.map((workflow: Workflow) => ({
|
||||
value: workflow.name,
|
||||
name: workflow.name,
|
||||
image: workflow.icon ?? "/file-code.svg",
|
||||
onDelete: async () => {
|
||||
await invoke("delete_workflow", {
|
||||
name: workflow.name.replace(/ /g, "_").toLowerCase(),
|
||||
});
|
||||
setWorkflows(workflows.filter((w) => w.name !== workflow.name));
|
||||
},
|
||||
})),
|
||||
|
||||
{
|
||||
value: " default",
|
||||
image: "/rotate.svg",
|
||||
component: (onClick: any) => (
|
||||
<SlightBox
|
||||
onClick={(e) => {
|
||||
Comfy.get()?.loadGraphData();
|
||||
onClick(e);
|
||||
}}
|
||||
name="Load default"
|
||||
icon={<Theme.Icon.Rotate color="#7F7F7F" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[setWorkflows, workflows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme.Popout
|
||||
title="Workflows"
|
||||
label="Workflow"
|
||||
options={options}
|
||||
value={null}
|
||||
placeholder={"Select workflow"}
|
||||
className={classes(className)}
|
||||
onClick={applyWorkflow}
|
||||
/>
|
||||
<Modal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { Style, Styles } from "./Style";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { Upscale, Upscales } from "./Upscale";
|
||||
import { Variations } from "./Variation";
|
||||
import { Workflow } from "./Workflow";
|
||||
|
||||
export * from "./Images";
|
||||
|
||||
@@ -227,6 +228,7 @@ export declare namespace Image {
|
||||
Upscales,
|
||||
Upscale,
|
||||
Exception,
|
||||
Workflow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,6 +262,7 @@ export namespace Image {
|
||||
Image.Upscales = Upscales;
|
||||
Image.Upscale = Upscale;
|
||||
Image.Exception = Exception;
|
||||
Image.Workflow = Workflow;
|
||||
|
||||
export const get = (id: ID): Image | undefined =>
|
||||
Images.State.use(({ images }) => images[id]);
|
||||
|
||||
@@ -425,6 +425,70 @@ export function Upscale(props: Props) {
|
||||
));
|
||||
}
|
||||
|
||||
export function Rotate(props: Props) {
|
||||
return defaults(props)(({ width, height, color, ...props }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7 12C7 12.9889 7.29324 13.9556 7.84265 14.7778C8.39206 15.6001 9.17295 16.241 10.0866 16.6194C11.0002 16.9978 12.0055 17.0969 12.9754 16.9039C13.9454 16.711 14.8363 16.2348 15.5355 15.5355C16.2348 14.8363 16.711 13.9454 16.9039 12.9754C17.0969 12.0055 16.9978 11.0002 16.6194 10.0866C16.241 9.17295 15.6001 8.39206 14.7778 7.84265C13.9556 7.29324 12.9889 7 12 7C10.6022 7.00526 9.26054 7.55068 8.25556 8.52222L7 9.77778"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d="M7 7V10H10"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
));
|
||||
}
|
||||
|
||||
export function Save(props: Props) {
|
||||
return defaults(props)(({ width, height, color, ...props }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
strokeWidth="0.9"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15.8889 17H8.11111C7.81643 17 7.53381 16.8829 7.32544 16.6746C7.11706 16.4662 7 16.1836 7 15.8889V8.11111C7 7.81643 7.11706 7.53381 7.32544 7.32544C7.53381 7.11706 7.81643 7 8.11111 7H14.2222L17 9.77778V15.8889C17 16.1836 16.8829 16.4662 16.6746 16.6746C16.4662 16.8829 16.1836 17 15.8889 17Z"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d="M15 17V13H9V17"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d="M9 7V10H13"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
));
|
||||
}
|
||||
|
||||
const defaults =
|
||||
(props: Props) =>
|
||||
(render: (props: Props) => JSX.Element): JSX.Element =>
|
||||
|
||||
@@ -77,6 +77,8 @@ import {
|
||||
Instagram,
|
||||
ModelIcon,
|
||||
Rectangle,
|
||||
Rotate,
|
||||
Save,
|
||||
Scale,
|
||||
ShareIcon,
|
||||
SlidersIcon,
|
||||
@@ -169,6 +171,8 @@ export declare namespace Icon {
|
||||
Upscale,
|
||||
Keyboard,
|
||||
ChevronsLeftRight,
|
||||
Rotate,
|
||||
Save,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,6 +266,8 @@ export namespace Icon {
|
||||
Icon.Upscale = makeComponent(Upscale);
|
||||
Icon.Keyboard = makeComponent(Keyboard);
|
||||
Icon.ChevronsLeftRight = makeComponent(ChevronsLeftRight);
|
||||
Icon.Rotate = makeComponent(Rotate);
|
||||
Icon.Save = makeComponent(Save);
|
||||
|
||||
export function Invisible(props: Props) {
|
||||
return (
|
||||
|
||||
@@ -5,11 +5,18 @@ import { Box } from "~/Geometry";
|
||||
import { Theme } from "~/Theme";
|
||||
|
||||
export type Option = {
|
||||
name: React.ReactNode;
|
||||
value: any;
|
||||
image?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
onDelete?: () => void;
|
||||
} & (
|
||||
| {
|
||||
name: React.ReactNode;
|
||||
}
|
||||
| {
|
||||
component: (onClick: (value: any) => void) => React.ReactNode;
|
||||
}
|
||||
);
|
||||
|
||||
export function Popout({
|
||||
onClick,
|
||||
@@ -109,7 +116,11 @@ export function Popout({
|
||||
/>
|
||||
)}
|
||||
<h1 className="w-full grow select-none">
|
||||
{valueOption?.name ?? placeholder ?? "Select"}
|
||||
{(valueOption && "name" in valueOption
|
||||
? valueOption?.name
|
||||
: undefined) ??
|
||||
placeholder ??
|
||||
"Select"}
|
||||
</h1>
|
||||
<Theme.Icon.ChevronRight className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
@@ -154,40 +165,61 @@ function Floating({
|
||||
>
|
||||
{!!children
|
||||
? children
|
||||
: options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={
|
||||
option.disabled ? doNothing : () => onClick(option.value)
|
||||
}
|
||||
className={classes(
|
||||
"group flex cursor-pointer flex-col rounded duration-100",
|
||||
option.disabled && "opacity-muted cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{option.image ? (
|
||||
<img
|
||||
className="mb-2 aspect-square h-full w-full rounded-lg border border-transparent duration-100 group-hover:border-zinc-200"
|
||||
src={option.image}
|
||||
alt="Preset Image"
|
||||
/>
|
||||
) : (
|
||||
hasImages && (
|
||||
<div className="mb-2 flex aspect-square w-full items-center justify-center rounded-lg border border-transparent bg-black/20 duration-100 group-hover:border-zinc-200">
|
||||
<Theme.Icon.Slash className="opacity-muted h-12 w-12" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<h1
|
||||
className={classes(
|
||||
"w-full grow select-none text-zinc-400 group-hover:text-zinc-200",
|
||||
option.value === value && "font-medium text-white"
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</h1>
|
||||
</div>
|
||||
))}
|
||||
: options.map(
|
||||
(option, index) =>
|
||||
("component" in option && option.component(onClick)) || (
|
||||
<div
|
||||
key={`${option.value}-${index}-option`}
|
||||
onClick={
|
||||
option.disabled ? doNothing : () => onClick(option.value)
|
||||
}
|
||||
className={classes(
|
||||
"group relative flex cursor-pointer flex-col rounded duration-100",
|
||||
option.disabled && "opacity-muted cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{option.onDelete && (
|
||||
<Theme.Tooltip
|
||||
content="Delete"
|
||||
placement="right"
|
||||
showArrow
|
||||
distance={15}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
option.onDelete?.();
|
||||
}}
|
||||
className="group/delete pointer-events-none absolute -right-4 -top-4 h-fit w-fit scale-50 cursor-pointer rounded-xl bg-zinc-900 p-1 text-white/75 opacity-0 duration-150 ease-in-out hover:text-white group-hover:pointer-events-auto group-hover:-right-2 group-hover:-top-2 group-hover:scale-100 group-hover:opacity-100"
|
||||
>
|
||||
<Theme.Icon.X color="currentColor" />
|
||||
</div>
|
||||
</Theme.Tooltip>
|
||||
)}
|
||||
{option.image ? (
|
||||
<img
|
||||
className="mb-2 aspect-square w-full select-none rounded-lg border border-transparent object-cover duration-100 group-hover:border-zinc-200"
|
||||
src={option.image}
|
||||
alt="Preset Image"
|
||||
/>
|
||||
) : (
|
||||
hasImages && (
|
||||
<div className="mb-2 flex aspect-square w-full items-center justify-center rounded-lg border border-transparent bg-black/20 duration-100 group-hover:border-zinc-200">
|
||||
<Theme.Icon.Slash className="opacity-muted h-12 w-12" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<h1
|
||||
className={classes(
|
||||
"w-full grow select-none text-zinc-400 group-hover:text-zinc-200",
|
||||
option.value === value && "font-medium text-white"
|
||||
)}
|
||||
>
|
||||
{"name" in option && option.name}
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user