Files
image-studio/view/bottom-controls/SelectMenu.tsx
syntaxbullet 7188569672 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.
2026-07-04 15:10:30 +02:00

153 lines
5.9 KiB
TypeScript

import { Check, CaretDown } from "@phosphor-icons/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;
label: string;
};
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, 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) => {
const target = event.target as Node;
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
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, placement]);
return (
<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 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)}
>
{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 && 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>
);
});