mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5d60d98f | ||
|
|
d3d5e29fae |
@@ -51,7 +51,7 @@ export const Input = React.forwardRef(
|
||||
ref={ref}
|
||||
id={id ? id : label ? kebabCase(label) : undefined}
|
||||
className={twMerge(
|
||||
"h-10 w-full rounded-lg bg-brand-secondary/80 px-3 py-2.5 text-white placeholder-white/50 outline-none",
|
||||
"h-10 w-full rounded-lg bg-[#242424] px-3 py-2.5 text-white placeholder-white/50 outline-none",
|
||||
"border border-[#363636] transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:!outline-none",
|
||||
icon && "pl-10",
|
||||
isError && "outline-red focus:outline-red",
|
||||
|
||||
@@ -23,10 +23,11 @@ interface PopoverMenuProps {
|
||||
children?: ReactNode;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
onAction?: (action: string) => void;
|
||||
onPanelAction?: (action: string) => void;
|
||||
panelTitle?: string;
|
||||
position?: "top" | "bottom";
|
||||
align?: "start" | "center" | "end";
|
||||
panelActionLabel?: string;
|
||||
}
|
||||
|
||||
export const PopoverMenu = ({
|
||||
@@ -41,16 +42,17 @@ export const PopoverMenu = ({
|
||||
children,
|
||||
buttonClassName,
|
||||
panelClassName,
|
||||
onAction,
|
||||
onPanelAction,
|
||||
panelTitle,
|
||||
position = "top",
|
||||
align = "start",
|
||||
panelActionLabel,
|
||||
}: PopoverMenuProps) => {
|
||||
const selectedItem = items.find((item) => item.selected);
|
||||
|
||||
const handleItemClick = (item: PopoverItem, close: () => void) => {
|
||||
if (mode === "button" && item.action && onAction) {
|
||||
onAction(item.action);
|
||||
if (mode === "button" && item.action && onPanelAction) {
|
||||
onPanelAction(item.action);
|
||||
close();
|
||||
} else {
|
||||
onSelect?.(item);
|
||||
@@ -124,8 +126,19 @@ export const PopoverMenu = ({
|
||||
)}
|
||||
>
|
||||
{panelTitle && (
|
||||
<div className="mb-2 mt-0.5 px-1.5 text-sm font-normal text-white opacity-70">
|
||||
<div className="mb-2 mt-0.5 flex justify-between px-1.5 text-sm font-normal text-white opacity-70">
|
||||
{panelTitle}
|
||||
{panelActionLabel && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onPanelAction?.(panelActionLabel);
|
||||
close();
|
||||
}}
|
||||
className="text-end text-sm text-white/85 hover:underline"
|
||||
>
|
||||
{panelActionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "default" && children ? (
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import React, { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "../Button";
|
||||
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
interface SliderProps {
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
step: number;
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
tooltipContent?: ReactNode;
|
||||
suffix?: string;
|
||||
innerLabel?: string;
|
||||
showDecrement?: boolean;
|
||||
showIncrement?: boolean;
|
||||
}
|
||||
|
||||
export const SliderV2 = ({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
step,
|
||||
className,
|
||||
showTooltip,
|
||||
tooltipContent,
|
||||
suffix,
|
||||
innerLabel,
|
||||
showDecrement,
|
||||
showIncrement,
|
||||
}: SliderProps) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showHandleTooltip, setShowHandleTooltip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const setClampedValue = (value: number) => {
|
||||
const roundedValue = Math.round(value / step) * step;
|
||||
const clampedValue = Math.min(Math.max(roundedValue, min), max);
|
||||
setLocalValue(clampedValue);
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
const updatePosition = (x: number) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const fraction = (x - rect.left) / rect.width;
|
||||
const newValue = fraction * (max - min) + min;
|
||||
setClampedValue(newValue);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setShowHandleTooltip(true);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
updatePosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
updatePosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
setShowHandleTooltip(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isDragging) {
|
||||
setShowHandleTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isDragging) {
|
||||
setShowHandleTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
setClampedValue(localValue - step);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
setClampedValue(localValue + step);
|
||||
};
|
||||
|
||||
const percentage = ((localValue - min) / (max - min)) * 100;
|
||||
const displayValue =
|
||||
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
|
||||
(suffix || "");
|
||||
|
||||
return (
|
||||
<div className="flex w-full">
|
||||
{showDecrement && (
|
||||
<Button
|
||||
icon={faMinus}
|
||||
className="focus-visible:outline-primary my-auto mr-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
|
||||
onClick={handleDecrement}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={twMerge(
|
||||
"glass border-ui-border group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border",
|
||||
isDragging && "!bg-ui-controls/90",
|
||||
className,
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
|
||||
isDragging && "!bg-brand-primary/50",
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
>
|
||||
{innerLabel && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
|
||||
isDragging && "!text-white",
|
||||
)}
|
||||
>
|
||||
{innerLabel}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute right-0 top-1/2 mr-1 h-4 w-0.5 -translate-y-1/2 rounded-full",
|
||||
isDragging ? "bg-white" : "bg-white/50",
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showIncrement && (
|
||||
<Button
|
||||
icon={faPlus}
|
||||
className="focus-visible:outline-primary my-auto ml-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
|
||||
onClick={handleIncrement}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTooltip && (
|
||||
<Transition
|
||||
as={"div"}
|
||||
show={showHandleTooltip}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="absolute -top-8 z-10 rounded-md bg-ui-panel px-2 py-1 text-xs text-black shadow-lg"
|
||||
style={{ left: `${percentage}%`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
{tooltipContent ? tooltipContent : displayValue}
|
||||
</div>
|
||||
</Transition>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SliderV2";
|
||||
@@ -79,7 +79,7 @@ export const TransitionDialogue = ({
|
||||
<Dialog.Panel
|
||||
className={twMerge(
|
||||
"w-full max-w-lg transform rounded-xl",
|
||||
"border border-ui-panel-border bg-ui-panel",
|
||||
"border border-ui-panel-border bg-[#2C2C2C]",
|
||||
"text-left align-middle shadow-xl transition-all",
|
||||
className,
|
||||
)}
|
||||
@@ -130,7 +130,9 @@ export const TransitionDialogue = ({
|
||||
)}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div className={`${childPadding ? "p-5 pt-0" : ""}`.trim()}>
|
||||
<div
|
||||
className={`h-full ${childPadding ? "p-5 pt-0" : ""}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CameraSettingsModal } from "./CameraSettingsModal";
|
||||
@@ -0,0 +1,160 @@
|
||||
import { TransitionDialogue } from "~/components/reusable/TransitionDialogue";
|
||||
import { faPlus, faTrashAlt, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { PopoverItem } from "~/components/reusable/Popover";
|
||||
import { Button, Input, Label, Tooltip } from "~/components";
|
||||
import { SliderV2 } from "~/components/reusable/SliderV2/SliderV2";
|
||||
|
||||
interface ExtendedPopoverItem extends PopoverItem {
|
||||
id: string;
|
||||
focalLength: number;
|
||||
}
|
||||
|
||||
interface CameraSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
cameras: ExtendedPopoverItem[];
|
||||
onCameraNameChange: (id: string, newName: string) => void;
|
||||
onCameraFocalLengthChange: (id: string, value: number) => void;
|
||||
onAddCamera: () => void;
|
||||
selectedCameraId: string;
|
||||
onSelectCamera: (id: string) => void;
|
||||
onDeleteCamera: (id: string) => void;
|
||||
}
|
||||
|
||||
export const CameraSettingsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
cameras,
|
||||
onCameraNameChange,
|
||||
onCameraFocalLengthChange,
|
||||
onAddCamera,
|
||||
selectedCameraId,
|
||||
onSelectCamera,
|
||||
onDeleteCamera,
|
||||
}: CameraSettingsModalProps) => {
|
||||
const selectedCamera = cameras.find((cam) => cam.id === selectedCameraId);
|
||||
|
||||
return (
|
||||
<TransitionDialogue
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
className="h-[500px] max-w-3xl"
|
||||
childPadding={false}
|
||||
>
|
||||
<div className="grid h-full grid-cols-12 gap-3">
|
||||
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:dark:bg-white/10">
|
||||
<div className="flex items-center justify-between gap-2.5 py-0.5">
|
||||
<h2 className="text-[18px] font-semibold opacity-80">Camera</h2>
|
||||
<Tooltip content="Add camera" position="top" delay={200}>
|
||||
<button
|
||||
className="h-6 w-6 rounded-full text-white/70 transition-colors hover:text-white/100"
|
||||
onClick={onAddCamera}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="text-xl" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<hr className="my-2 w-full border-white/10" />
|
||||
<div className="space-y-1">
|
||||
{cameras.map((camera) => (
|
||||
<button
|
||||
key={camera.id}
|
||||
className={`w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${
|
||||
camera.id === selectedCameraId ? "bg-[#63636B]/40" : ""
|
||||
}`}
|
||||
onClick={() => onSelectCamera(camera.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-sm">
|
||||
{camera.icon}
|
||||
{camera.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-8 p-3 ps-0 pt-2">
|
||||
<div className="flex h-full flex-col">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2.5 py-0.5 opacity-100">
|
||||
<h2 className="text-[18px] font-semibold">
|
||||
{selectedCamera?.label || "Camera"}
|
||||
</h2>
|
||||
<Tooltip
|
||||
content={
|
||||
selectedCamera?.id === "main"
|
||||
? "Cannot delete the main camera"
|
||||
: "Delete camera"
|
||||
}
|
||||
position="top"
|
||||
delay={200}
|
||||
>
|
||||
<button
|
||||
className={`h-6 w-6 rounded-lg transition-colors ${
|
||||
selectedCamera?.id === "main"
|
||||
? "cursor-not-allowed text-white/30"
|
||||
: "text-white/60 hover:text-white/100"
|
||||
}`}
|
||||
onClick={() =>
|
||||
selectedCamera &&
|
||||
selectedCamera.id !== "main" &&
|
||||
onDeleteCamera(selectedCamera.id)
|
||||
}
|
||||
disabled={selectedCamera?.id === "main"}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="text-lg" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<hr className="my-2 w-full border-white/10" />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="camera-name" className="text-sm opacity-70">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="camera-name"
|
||||
type="text"
|
||||
value={selectedCamera?.label || ""}
|
||||
onChange={(e) =>
|
||||
selectedCamera &&
|
||||
onCameraNameChange(selectedCamera.id, e.target.value)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="focal-length" className="text-sm opacity-70">
|
||||
Focal Length
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<SliderV2
|
||||
min={10}
|
||||
max={200}
|
||||
value={selectedCamera?.focalLength || 35}
|
||||
onChange={(value) =>
|
||||
selectedCamera &&
|
||||
onCameraFocalLengthChange(selectedCamera.id, value)
|
||||
}
|
||||
step={1}
|
||||
suffix="mm"
|
||||
showDecrement={true}
|
||||
showIncrement={true}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="min-w-[60px] text-sm">
|
||||
{selectedCamera?.focalLength || 35}mm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex justify-end pt-4">
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionDialogue>
|
||||
);
|
||||
};
|
||||
@@ -48,6 +48,12 @@ interface ReferenceImage {
|
||||
import { EngineApi } from "~/Classes/ApiManager/EngineApi";
|
||||
import { scene } from "~/signals/scene";
|
||||
import { UploaderStates } from "~/enums/UploaderStates";
|
||||
import { CameraSettingsModal } from "./CameraSettingsModal";
|
||||
|
||||
interface ExtendedPopoverItem extends PopoverItem {
|
||||
id: string;
|
||||
focalLength: number;
|
||||
}
|
||||
|
||||
export const PromptBox = () => {
|
||||
useSignals();
|
||||
@@ -78,20 +84,26 @@ export const PromptBox = () => {
|
||||
icon: <FontAwesomeIcon icon={faRectangleWide} className="h-4 w-4" />,
|
||||
},
|
||||
]);
|
||||
const [cameraList, setCameraList] = useState<PopoverItem[]>([
|
||||
const [cameraList, setCameraList] = useState<ExtendedPopoverItem[]>([
|
||||
{
|
||||
id: "main",
|
||||
label: "Main View",
|
||||
selected: true,
|
||||
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
|
||||
focalLength: 35,
|
||||
},
|
||||
{
|
||||
id: "cam2",
|
||||
label: "Camera 2",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
|
||||
focalLength: 35,
|
||||
},
|
||||
]);
|
||||
const [selectedCameraId, setSelectedCameraId] = useState("main");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
@@ -126,20 +138,41 @@ export const PromptBox = () => {
|
||||
selected: item.label === selectedItem.label,
|
||||
})),
|
||||
);
|
||||
const selectedCamera = cameraList.find(
|
||||
(cam) => cam.label === selectedItem.label,
|
||||
);
|
||||
if (selectedCamera) {
|
||||
setSelectedCameraId(selectedCamera.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCamera = () => {
|
||||
const newIndex = cameraList.length + 1;
|
||||
const newId = `cam${newIndex}`;
|
||||
setCameraList((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId,
|
||||
label: `Camera ${newIndex}`,
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
|
||||
focalLength: 35,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleCameraNameChange = (id: string, newName: string) => {
|
||||
setCameraList((prev) =>
|
||||
prev.map((cam) => (cam.id === id ? { ...cam, label: newName } : cam)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCameraFocalLengthChange = (id: string, value: number) => {
|
||||
setCameraList((prev) =>
|
||||
prev.map((cam) => (cam.id === id ? { ...cam, focalLength: value } : cam)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
|
||||
setAspectRatioList((prev) =>
|
||||
prev.map((item) => ({
|
||||
@@ -326,6 +359,31 @@ export const PromptBox = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCamera = (id: string) => {
|
||||
// Don't allow deleting the main camera or if it's the last camera
|
||||
if (id === "main" || cameraList.length <= 1) return;
|
||||
|
||||
// Update camera list
|
||||
setCameraList((prev) => prev.filter((cam) => cam.id !== id));
|
||||
|
||||
// If we're deleting the selected camera, select the first remaining camera
|
||||
if (id === selectedCameraId) {
|
||||
const remainingCameras = cameraList.filter((cam) => cam.id !== id);
|
||||
if (remainingCameras.length > 0) {
|
||||
setSelectedCameraId(remainingCameras[0].id);
|
||||
// Update selection state
|
||||
setCameraList((prev) =>
|
||||
prev
|
||||
.filter((cam) => cam.id !== id)
|
||||
.map((cam, idx) => ({
|
||||
...cam,
|
||||
selected: idx === 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
|
||||
{referenceImages.length > 0 && (
|
||||
@@ -429,6 +487,8 @@ export const PromptBox = () => {
|
||||
showIconsInList
|
||||
mode="toggle"
|
||||
panelTitle="Camera"
|
||||
panelActionLabel="Settings"
|
||||
onPanelAction={() => setIsCameraSettingsOpen(true)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -489,6 +549,17 @@ export const PromptBox = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CameraSettingsModal
|
||||
isOpen={isCameraSettingsOpen}
|
||||
onClose={() => setIsCameraSettingsOpen(false)}
|
||||
cameras={cameraList}
|
||||
onCameraNameChange={handleCameraNameChange}
|
||||
onCameraFocalLengthChange={handleCameraFocalLengthChange}
|
||||
onAddCamera={handleAddCamera}
|
||||
selectedCameraId={selectedCameraId}
|
||||
onSelectCamera={setSelectedCameraId}
|
||||
onDeleteCamera={handleDeleteCamera}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user