feat: add ComfyUI integration for image generation
- Implemented ComfyUI API for generating images with various modes (text-to-image, image-to-image, inpaint, outpaint). - Created GenerateSheet and associated controls for user input on generation settings. - Added subtle scrollbar styles for improved UI experience. - Enhanced canvas input handling to ignore key events when focused on editable elements. - Optimized canvas resizing logic to prevent unnecessary dispatches. - Introduced error handling for generation failures and loading models. - Added functionality to upload images and masks for inpainting.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Check, CaretDown } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useRef, useState, type CSSProperties, type ForwardedRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type BottomControlSelectOption<TValue extends string> = {
|
||||
value: TValue;
|
||||
@@ -10,19 +11,43 @@ export type BottomControlSelectMenuProps<TValue extends string> = {
|
||||
value: TValue;
|
||||
options: readonly BottomControlSelectOption<TValue>[];
|
||||
"aria-label": string;
|
||||
label?: string;
|
||||
placement?: "top" | "bottom" | "inline";
|
||||
onValueChange: (value: TValue) => void;
|
||||
};
|
||||
|
||||
export function BottomControlSelectMenu<TValue extends string>({ value, options, onValueChange, ...props }: BottomControlSelectMenuProps<TValue>) {
|
||||
export function BottomControlSelectMenu<TValue extends string>({ value, options, label, placement = "top", onValueChange, ...props }: BottomControlSelectMenuProps<TValue>) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>();
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updateMenuPosition = () => {
|
||||
if (placement === "inline") return;
|
||||
const rect = buttonRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const gap = 8;
|
||||
const maxHeight = Math.max(160, placement === "bottom" ? window.innerHeight - rect.bottom - gap * 2 : rect.top - gap * 2);
|
||||
setMenuStyle({
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
top: placement === "bottom" ? rect.bottom + gap : undefined,
|
||||
bottom: placement === "top" ? window.innerHeight - rect.top + gap : undefined,
|
||||
width: Math.max(rect.width, 192),
|
||||
maxHeight,
|
||||
});
|
||||
};
|
||||
|
||||
updateMenuPosition();
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -31,50 +56,97 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", updateMenuPosition);
|
||||
window.addEventListener("scroll", updateMenuPosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", updateMenuPosition);
|
||||
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, placement]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="inline-flex h-10 min-w-32 items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
|
||||
className={`inline-flex h-10 min-w-32 max-w-full items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${placement === "inline" ? "w-full px-4" : ""}`}
|
||||
aria-label={props["aria-label"]}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 px-4 text-left">{selectedOption?.label}</span>
|
||||
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
|
||||
<span className={`min-w-0 flex-1 truncate text-left ${placement === "inline" ? "" : "px-4"}`}>{selectedOption?.label}</span>
|
||||
<span className="grid size-10 flex-none place-items-center text-white/60">
|
||||
<CaretDown size={18} weight="bold" />
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute bottom-full left-0 z-30 mb-3 min-w-full rounded-[1.5rem] p-1 text-white backdrop-blur-xl" role="listbox" aria-label={props["aria-label"]}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="grid size-5 place-items-center">{selected ? <Check size={16} weight="bold" /> : null}</span>
|
||||
<span className="min-w-0 flex-1 whitespace-nowrap">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{open && placement === "inline" ? (
|
||||
<SelectOptions
|
||||
ref={menuRef}
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
setOpen={setOpen}
|
||||
className="subtle-scrollbar mt-2 max-h-56 w-full overflow-auto rounded-[1.25rem] bg-white/[0.04] p-1 text-white ring-1 ring-white/10"
|
||||
aria-label={props["aria-label"]}
|
||||
/>
|
||||
) : open ? (
|
||||
createPortal(
|
||||
<SelectOptions
|
||||
ref={menuRef}
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
setOpen={setOpen}
|
||||
className="subtle-scrollbar z-50 overflow-auto rounded-[1.5rem] bg-slate-950/90 p-1 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl"
|
||||
style={menuStyle}
|
||||
aria-label={props["aria-label"]}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectOptionsProps<TValue extends string> = {
|
||||
options: readonly BottomControlSelectOption<TValue>[];
|
||||
value: TValue;
|
||||
className: string;
|
||||
style?: CSSProperties;
|
||||
"aria-label": string;
|
||||
onValueChange: (value: TValue) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const SelectOptions = forwardRef(function SelectOptions<TValue extends string>(
|
||||
{ options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
return (
|
||||
<div ref={ref} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="grid size-5 place-items-center">{selected ? <Check size={16} weight="bold" /> : null}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user