mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c3940061 | ||
|
|
5c211b8dd7 |
@@ -12,6 +12,7 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
FilterMediaClasses,
|
||||
FilterMediaType,
|
||||
GalleryModalApi,
|
||||
UsersApi,
|
||||
} from "@storyteller/api";
|
||||
@@ -218,7 +219,8 @@ export const GalleryModal = React.memo(
|
||||
include_user_uploads:
|
||||
activeFilter === "uploaded" ||
|
||||
activeFilter === "all" ||
|
||||
activeFilter === "3d",
|
||||
activeFilter === "3d" ||
|
||||
activeFilter === "image",
|
||||
user_uploads_only: activeFilter === "uploaded",
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: pageSize,
|
||||
@@ -227,26 +229,30 @@ export const GalleryModal = React.memo(
|
||||
|
||||
if (response.success && response.data) {
|
||||
const thumbnail_size = 250;
|
||||
const newItems = response.data.map((item: any) => ({
|
||||
id: item.token,
|
||||
label: item.maybe_title || "Image Generation",
|
||||
thumbnail:
|
||||
item.media_class === "video"
|
||||
? item.media_links.maybe_video_previews.still
|
||||
: item.media_class === "dimensional"
|
||||
? item.cover_image?.maybe_cover_image_public_bucket_url
|
||||
: item.media_links.maybe_thumbnail_template?.replace(
|
||||
"{WIDTH}",
|
||||
thumbnail_size.toString()
|
||||
),
|
||||
fullImage: item.media_links.cdn_url,
|
||||
createdAt: item.created_at,
|
||||
mediaClass:
|
||||
item.media_class ||
|
||||
(item.filter_media_classes
|
||||
? item.filter_media_classes[0]
|
||||
: "image"),
|
||||
}));
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
|
||||
)
|
||||
.map((item: any) => ({
|
||||
id: item.token,
|
||||
label: item.maybe_title || "Image Generation",
|
||||
thumbnail:
|
||||
item.media_class === "video"
|
||||
? item.media_links.maybe_video_previews.still
|
||||
: item.media_class === "dimensional"
|
||||
? item.cover_image?.maybe_cover_image_public_bucket_url
|
||||
: item.media_links.maybe_thumbnail_template?.replace(
|
||||
"{WIDTH}",
|
||||
thumbnail_size.toString()
|
||||
),
|
||||
fullImage: item.media_links.cdn_url,
|
||||
createdAt: item.created_at,
|
||||
mediaClass:
|
||||
item.media_class ||
|
||||
(item.filter_media_classes
|
||||
? item.filter_media_classes[0]
|
||||
: "image"),
|
||||
}));
|
||||
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
|
||||
// Pagination logic
|
||||
const current = response.pagination?.current ?? 0;
|
||||
@@ -355,13 +361,13 @@ export const GalleryModal = React.memo(
|
||||
}}
|
||||
className={twMerge(
|
||||
"h-[620px] max-w-4xl",
|
||||
mode === "view" && "h-[640px] max-w-4xl"
|
||||
mode === "view" && "h-[640px] w-[56rem]"
|
||||
)}
|
||||
childPadding={false}
|
||||
showClose={false}
|
||||
draggable={mode === "view"}
|
||||
allowBackgroundInteraction={mode === "view" ? true : false}
|
||||
closeOnOutsideClick={false}
|
||||
closeOnOutsideClick={mode === "view" ? false : true}
|
||||
>
|
||||
{mode === "view" && (
|
||||
<Modal.DragHandle>
|
||||
@@ -526,10 +532,23 @@ export const GalleryModal = React.memo(
|
||||
{Object.entries(groupItemsByDate(allItems)).map(
|
||||
([date, dateItems]) => {
|
||||
const filteredItems = dateItems.filter((item) => {
|
||||
if (activeFilter === "3d")
|
||||
if ((item as any).mediaType === "scene_json")
|
||||
return false;
|
||||
if (activeFilter === "3d") {
|
||||
return item.mediaClass === "dimensional";
|
||||
if (activeFilter === "all")
|
||||
return item.mediaClass !== "audio";
|
||||
}
|
||||
if (activeFilter === "image") {
|
||||
return item.mediaClass === "image";
|
||||
}
|
||||
if (activeFilter === "video") {
|
||||
return item.mediaClass === "video";
|
||||
}
|
||||
if (activeFilter === "all") {
|
||||
return (
|
||||
item.mediaClass !== "audio" &&
|
||||
(item as any).mediaType !== "scene_json"
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (filteredItems.length === 0) return null;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { CloseButton } from "@storyteller/ui-close-button";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Transition, TransitionChild } from "@headlessui/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dayjs from "dayjs";
|
||||
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
FalHunyuanImageTo3d,
|
||||
EnqueueImageTo3dObject,
|
||||
EnqueueImageTo3dObjectModel,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import React from "react";
|
||||
|
||||
interface LightboxModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -46,156 +45,145 @@ export function LightboxModal({
|
||||
onAddToSceneClicked,
|
||||
mediaClass,
|
||||
}: LightboxModalProps) {
|
||||
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
|
||||
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
|
||||
// the <image> tag request from being cached. If we then drag it into the canvas after it's been cached,
|
||||
// it won't be able to load in cors mode and will show blank in the canvas and 3D engine. This is a really
|
||||
// stupid hack around this behavior.
|
||||
const imageTagImageUrl = imageUrl ? imageUrl + "?cors=1" : "";
|
||||
return createPortal(
|
||||
<Transition appear show={isOpen}>
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* backdrop */}
|
||||
<TransitionChild
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 cursor-pointer bg-black/60"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
{/* dialog container */}
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<TransitionChild
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* close */}
|
||||
<CloseButton
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 z-10"
|
||||
/>
|
||||
const [mediaLoaded, setMediaLoaded] = React.useState<boolean>(false);
|
||||
|
||||
{/* content grid */}
|
||||
<div className="grid h-full grid-cols-3 gap-6">
|
||||
{/* image panel */}
|
||||
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/40">
|
||||
{!imageUrl ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-800">
|
||||
<span className="text-white/60">Image not available</span>
|
||||
</div>
|
||||
) : mediaClass === "video" ? (
|
||||
<video controls className="h-full w-full object-contain">
|
||||
<source src={imageUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<img
|
||||
src={imageTagImageUrl}
|
||||
alt={imageAlt}
|
||||
className="h-full w-full object-contain"
|
||||
onError={onImageError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
// Reset when imageUrl changes
|
||||
React.useEffect(() => {
|
||||
setMediaLoaded(false);
|
||||
}, [imageUrl]);
|
||||
|
||||
{/* info + actions */}
|
||||
<div className="flex h-full flex-col py-5 pe-5">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="text-xl font-medium">
|
||||
{title || "Image Generation"}
|
||||
</div>
|
||||
{createdAt && (
|
||||
<div className="text-sm text-white/60">
|
||||
Created:{" "}
|
||||
{dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
</div>
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
className="rounded-xl bg-[#2C2C2C] h-[80vh] w-[60vw] max-w-screen"
|
||||
draggable
|
||||
allowBackgroundInteraction={true}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={false}
|
||||
resizable={true}
|
||||
backdropClassName="pointer-events-none hidden"
|
||||
>
|
||||
{/* Invisible drag handle strip at the very top for moving */}
|
||||
<Modal.DragHandle>
|
||||
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
|
||||
</Modal.DragHandle>
|
||||
|
||||
{/* buttons with spacing */}
|
||||
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
|
||||
<div className="mt-15 mb-15 flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={async (e) => {
|
||||
//let _result = await FalHunyuanImageTo3d({
|
||||
// image_media_token: mediaId,
|
||||
// //base64_image: downloadUrl,
|
||||
//});
|
||||
let _result = await EnqueueImageTo3dObject({
|
||||
image_media_token: mediaId,
|
||||
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
|
||||
});
|
||||
//e.stopPropagation();
|
||||
//await onAddToSceneClicked(downloadUrl, mediaId);
|
||||
//onClose(); // close the lightbox
|
||||
//onCloseGallery(); // close the gallery
|
||||
}}
|
||||
>
|
||||
3D
|
||||
</Button>
|
||||
|
||||
{onAddToSceneClicked && downloadUrl && (
|
||||
<Button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await onAddToSceneClicked(downloadUrl, mediaId);
|
||||
onClose(); // close the lightbox
|
||||
onCloseGallery(); // close the gallery
|
||||
}}
|
||||
>
|
||||
Add to Current Scene
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{downloadUrl &&
|
||||
(onDownloadClicked ? (
|
||||
<Button
|
||||
icon={faDownToLine}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await onDownloadClicked(downloadUrl);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button icon={faDownToLine}>Download</Button>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* content grid */}
|
||||
<div className="grid h-full grid-cols-3 gap-6">
|
||||
{/* image panel */}
|
||||
<div className="col-span-2 relative flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-[#1A1A1A]">
|
||||
{!imageUrl ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-800">
|
||||
<span className="text-white/60">Image not available</span>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
) : mediaClass === "video" ? (
|
||||
<video
|
||||
controls
|
||||
className="h-full w-full object-contain"
|
||||
onLoadedData={() => setMediaLoaded(true)}
|
||||
>
|
||||
<source src={imageUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<img
|
||||
src={imageTagImageUrl}
|
||||
alt={imageAlt}
|
||||
className="h-full w-full object-contain"
|
||||
onError={onImageError}
|
||||
onLoad={() => setMediaLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!mediaLoaded && imageUrl && (
|
||||
<div className="absolute inset-0 bg-[#1A1A1A] flex items-center justify-center">
|
||||
<LoadingSpinner className="h-12 w-12 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* info + actions */}
|
||||
<div className="flex h-full flex-col py-5 pe-5">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="text-xl font-medium">
|
||||
{title || "Image Generation"}
|
||||
</div>
|
||||
{createdAt && (
|
||||
<div className="text-sm text-white/60">
|
||||
Created: {dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
</div>
|
||||
|
||||
{/* buttons with spacing */}
|
||||
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
|
||||
<div className="mt-15 mb-15 flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={async (e) => {
|
||||
//let _result = await FalHunyuanImageTo3d({
|
||||
// image_media_token: mediaId,
|
||||
// //base64_image: downloadUrl,
|
||||
//});
|
||||
let result = await EnqueueImageTo3dObject({
|
||||
image_media_token: mediaId,
|
||||
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
|
||||
});
|
||||
//e.stopPropagation();
|
||||
//await onAddToSceneClicked(downloadUrl, mediaId);
|
||||
//onClose(); // close the lightbox
|
||||
//onCloseGallery(); // close the gallery
|
||||
}}
|
||||
>
|
||||
3D
|
||||
</Button>
|
||||
|
||||
{onAddToSceneClicked && downloadUrl && (
|
||||
<Button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await onAddToSceneClicked(downloadUrl, mediaId);
|
||||
onClose(); // close the lightbox
|
||||
onCloseGallery(); // close the gallery
|
||||
}}
|
||||
>
|
||||
Add to Current Scene
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{downloadUrl &&
|
||||
(onDownloadClicked ? (
|
||||
<Button
|
||||
icon={faDownToLine}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await onDownloadClicked(downloadUrl);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button icon={faDownToLine}>Download</Button>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>,
|
||||
document.body
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,49 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { DomLevels } from "@storyteller/common";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GLOBAL inert / aria-hidden stripper – applies once per page load
|
||||
// Overrides the inert attribute set by Headless UI to allow background interaction / stacked modals
|
||||
// ---------------------------------------------------------------------------
|
||||
declare global {
|
||||
interface Window {
|
||||
__inertStripperInstalled?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.__inertStripperInstalled) {
|
||||
window.__inertStripperInstalled = true;
|
||||
|
||||
const strip = (el: Element) => {
|
||||
if (el.hasAttribute("inert")) el.removeAttribute("inert");
|
||||
// @ts-ignore – some browsers expose .inert as a property
|
||||
if ((el as any).inert) (el as any).inert = false;
|
||||
if (el.getAttribute("aria-hidden") === "true")
|
||||
el.removeAttribute("aria-hidden");
|
||||
};
|
||||
|
||||
// 1. Clean anything that already exists
|
||||
document.querySelectorAll("[inert]").forEach(strip);
|
||||
|
||||
// 2. Keep document clean forever
|
||||
const inertObserver = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === "attributes" && m.attributeName === "inert") {
|
||||
strip(m.target as Element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inertObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["inert"],
|
||||
});
|
||||
}
|
||||
|
||||
// Simple global z-index tracker for stacked modals
|
||||
let modalZCounter = 70;
|
||||
|
||||
const DialogBackdrop = ({
|
||||
className,
|
||||
onClose,
|
||||
@@ -120,8 +163,8 @@ export const Modal = ({
|
||||
titleIcon,
|
||||
onTitleIconClick,
|
||||
onClose,
|
||||
disableHotkeyInput = () => {},
|
||||
enableHotkeyInput = () => {},
|
||||
disableHotkeyInput = () => {},
|
||||
className,
|
||||
backdropClassName,
|
||||
width,
|
||||
@@ -130,6 +173,7 @@ export const Modal = ({
|
||||
titleIconClassName,
|
||||
showClose = true,
|
||||
draggable = false,
|
||||
resizable = false,
|
||||
initialPosition,
|
||||
closeOnOutsideClick = true,
|
||||
allowBackgroundInteraction = false,
|
||||
@@ -148,6 +192,7 @@ export const Modal = ({
|
||||
childPadding?: boolean;
|
||||
showClose?: boolean;
|
||||
draggable?: boolean;
|
||||
resizable?: boolean;
|
||||
disableHotkeyInput?: (level: number) => void;
|
||||
enableHotkeyInput?: (level: number) => void;
|
||||
/**
|
||||
@@ -172,11 +217,13 @@ export const Modal = ({
|
||||
null
|
||||
);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const dragStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const mouseStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const positionRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const lastPositionRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [zIndex, setZIndex] = useState<number>(() => ++modalZCounter);
|
||||
|
||||
// Expanded state
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -197,7 +244,7 @@ export const Modal = ({
|
||||
modalRef.current.style.top = "0px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = "70";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
} else {
|
||||
// Restore last non-expanded position
|
||||
@@ -210,12 +257,12 @@ export const Modal = ({
|
||||
modalRef.current.style.top = lastNonExpandedPosition.current.y + "px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = "70";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [expanded]);
|
||||
}, [expanded, zIndex]);
|
||||
const toggleExpanded = () => setExpanded((v) => !v);
|
||||
|
||||
// Reset position when modal is closed or opened
|
||||
@@ -237,7 +284,7 @@ export const Modal = ({
|
||||
modalRef.current.style.top = lastPositionRef.current.y + "px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = "70";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
} else if (initialPosition) {
|
||||
setPosition({ ...initialPosition });
|
||||
@@ -247,11 +294,11 @@ export const Modal = ({
|
||||
modalRef.current.style.top = initialPosition.y + "px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = "70";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, zIndex]);
|
||||
|
||||
// Handle mouse move and up events
|
||||
useEffect(() => {
|
||||
@@ -284,7 +331,7 @@ export const Modal = ({
|
||||
modalRef.current.style.top = positionRef.current.y + "px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = "70";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -301,20 +348,24 @@ export const Modal = ({
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
};
|
||||
}, [dragging]);
|
||||
}, [dragging, zIndex]);
|
||||
|
||||
// Center modal if not being dragged
|
||||
const getModalStyle = (): React.CSSProperties => {
|
||||
if (!draggable || !position) {
|
||||
// If allowBackgroundInteraction, set pointerEvents: 'auto' for modal
|
||||
return allowBackgroundInteraction ? { pointerEvents: "auto" } : {};
|
||||
return {
|
||||
...(size ? { width: size.width, height: size.height } : {}),
|
||||
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: "fixed",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
margin: 0,
|
||||
zIndex: 70,
|
||||
zIndex,
|
||||
...(size ? { width: size.width, height: size.height } : {}),
|
||||
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
|
||||
};
|
||||
};
|
||||
@@ -352,7 +403,7 @@ export const Modal = ({
|
||||
modalRef.current.style.top = y + "px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = "70";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -407,12 +458,301 @@ export const Modal = ({
|
||||
: children;
|
||||
}
|
||||
|
||||
/**
|
||||
* ------------------ RESIZING LOGIC --------------------
|
||||
*/
|
||||
const resizeDirRef = useRef<string | null>(null);
|
||||
const resizeStart = useRef<{
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent, dir: string) => {
|
||||
if (!modalRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (expanded) return; // don't allow resize when expanded
|
||||
const rect = modalRef.current.getBoundingClientRect();
|
||||
resizeDirRef.current = dir;
|
||||
resizeStart.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
};
|
||||
setResizing(true);
|
||||
};
|
||||
|
||||
// Handle resizing mouse move / up
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
let animationFrame: number | null = null;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!modalRef.current || !resizeStart.current || !resizeDirRef.current)
|
||||
return;
|
||||
const dx = e.clientX - resizeStart.current.mouseX;
|
||||
const dy = e.clientY - resizeStart.current.mouseY;
|
||||
|
||||
let newWidth = resizeStart.current.width;
|
||||
let newHeight = resizeStart.current.height;
|
||||
let newX = resizeStart.current.x;
|
||||
let newY = resizeStart.current.y;
|
||||
|
||||
const dir = resizeDirRef.current;
|
||||
|
||||
const minWidth = 320;
|
||||
const minHeight = 240;
|
||||
|
||||
if (dir.includes("right")) {
|
||||
newWidth = resizeStart.current.width + dx;
|
||||
}
|
||||
if (dir.includes("left")) {
|
||||
newWidth = resizeStart.current.width - dx;
|
||||
newX = resizeStart.current.x + dx;
|
||||
}
|
||||
if (dir.includes("bottom")) {
|
||||
newHeight = resizeStart.current.height + dy;
|
||||
}
|
||||
if (dir.includes("top")) {
|
||||
newHeight = resizeStart.current.height - dy;
|
||||
newY = resizeStart.current.y + dy;
|
||||
}
|
||||
|
||||
newWidth = Math.max(minWidth, newWidth);
|
||||
newHeight = Math.max(minHeight, newHeight);
|
||||
|
||||
positionRef.current = { x: newX, y: newY };
|
||||
sizeRef.current = { width: newWidth, height: newHeight };
|
||||
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
if (modalRef.current && positionRef.current) {
|
||||
modalRef.current.style.width = newWidth + "px";
|
||||
modalRef.current.style.height = newHeight + "px";
|
||||
modalRef.current.style.left = positionRef.current.x + "px";
|
||||
modalRef.current.style.top = positionRef.current.y + "px";
|
||||
modalRef.current.style.margin = "0";
|
||||
modalRef.current.style.position = "fixed";
|
||||
modalRef.current.style.zIndex = String(zIndex);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(false);
|
||||
if (positionRef.current) setPosition({ ...positionRef.current });
|
||||
if (sizeRef.current) setSize({ ...sizeRef.current });
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
};
|
||||
}, [resizing, zIndex]);
|
||||
|
||||
// Render resize handles if resizable and not expanded
|
||||
const renderResizeHandles = () => {
|
||||
if (!resizable || expanded) return null;
|
||||
const baseClass = "absolute z-[75] bg-transparent select-none";
|
||||
const handleSize = 5; // px
|
||||
const sideThickness = 2; // for edge handles
|
||||
const handles = [
|
||||
{ dir: "top", className: `top-0 left-0 w-full h-${sideThickness}` },
|
||||
{ dir: "bottom", className: `bottom-0 left-0 w-full h-${sideThickness}` },
|
||||
{ dir: "left", className: `left-0 top-0 h-full w-${sideThickness}` },
|
||||
{ dir: "right", className: `right-0 top-0 h-full w-${sideThickness}` },
|
||||
{
|
||||
dir: "top-left",
|
||||
className: `top-0 left-0 w-${handleSize} h-${handleSize}`,
|
||||
},
|
||||
{
|
||||
dir: "top-right",
|
||||
className: `top-0 right-0 w-${handleSize} h-${handleSize}`,
|
||||
},
|
||||
{
|
||||
dir: "bottom-left",
|
||||
className: `bottom-0 left-0 w-${handleSize} h-${handleSize}`,
|
||||
},
|
||||
{
|
||||
dir: "bottom-right",
|
||||
className: `bottom-0 right-0 w-${handleSize} h-${handleSize}`,
|
||||
},
|
||||
];
|
||||
|
||||
const cursorMap: Record<string, string> = {
|
||||
top: "n-resize",
|
||||
bottom: "s-resize",
|
||||
left: "w-resize",
|
||||
right: "e-resize",
|
||||
"top-left": "nw-resize",
|
||||
"top-right": "ne-resize",
|
||||
"bottom-left": "sw-resize",
|
||||
"bottom-right": "se-resize",
|
||||
};
|
||||
|
||||
return handles.map((h) => (
|
||||
<div
|
||||
key={h.dir}
|
||||
className={`${baseClass} ${h.className}`}
|
||||
style={{ cursor: cursorMap[h.dir] }}
|
||||
onMouseDown={(e) => handleResizeStart(e, h.dir)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
// Size (width & height) state for resizable modal
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
const sizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const lastSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
// Capture initial size on first open
|
||||
useEffect(() => {
|
||||
if (isOpen && !size && modalRef.current) {
|
||||
const { width, height } = modalRef.current.getBoundingClientRect();
|
||||
setSize({ width, height });
|
||||
sizeRef.current = { width, height };
|
||||
}
|
||||
}, [isOpen, size]);
|
||||
|
||||
// Persist size across close / reopen
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (sizeRef.current) lastSizeRef.current = { ...sizeRef.current };
|
||||
} else {
|
||||
if (lastSizeRef.current && modalRef.current) {
|
||||
const { width, height } = lastSizeRef.current;
|
||||
modalRef.current.style.width = width + "px";
|
||||
modalRef.current.style.height = height + "px";
|
||||
setSize({ width, height });
|
||||
sizeRef.current = { width, height };
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Bring to front when user interacts with modal (mouse down anywhere inside)
|
||||
useEffect(() => {
|
||||
const handleBringToFront = () => {
|
||||
if (modalRef.current) {
|
||||
if (zIndex < modalZCounter) {
|
||||
modalZCounter += 1;
|
||||
setZIndex(modalZCounter);
|
||||
modalRef.current.style.zIndex = String(modalZCounter);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const node = modalRef.current;
|
||||
if (node) {
|
||||
node.addEventListener("mousedown", handleBringToFront);
|
||||
}
|
||||
return () => {
|
||||
if (node) node.removeEventListener("mousedown", handleBringToFront);
|
||||
};
|
||||
}, [zIndex]);
|
||||
|
||||
// If background interaction is allowed, ensure this modal (and its ancestors)
|
||||
// never get the "inert" attribute Headless-UI uses to lock background dialogs.
|
||||
useEffect(() => {
|
||||
if (!allowBackgroundInteraction) return;
|
||||
const node = modalRef.current as HTMLElement | null;
|
||||
if (!node) return;
|
||||
|
||||
const stripInert = (el: HTMLElement | null) => {
|
||||
if (!el) return;
|
||||
if (el.hasAttribute("inert")) el.removeAttribute("inert");
|
||||
// Also clear the property for browsers implementing it as mutable prop
|
||||
// @ts-ignore
|
||||
if ((el as any).inert) (el as any).inert = false;
|
||||
if (el.getAttribute("aria-hidden") === "true") {
|
||||
el.removeAttribute("aria-hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// Remove inert from this modal and all its ancestors (Headless UI sets it on the modal container)
|
||||
let cur: HTMLElement | null = node;
|
||||
while (cur) {
|
||||
stripInert(cur);
|
||||
cur = cur.parentElement as HTMLElement | null;
|
||||
}
|
||||
|
||||
// MutationObserver to keep stripping inert from THIS modal element if reapplied
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((m) => {
|
||||
if (
|
||||
m.type === "attributes" &&
|
||||
m.attributeName === "inert" &&
|
||||
m.target instanceof HTMLElement
|
||||
) {
|
||||
const target = m.target as HTMLElement;
|
||||
// Only touch if the target is this modal or one of its ancestors
|
||||
if (target === node || node.contains(target)) {
|
||||
stripInert(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(node, {
|
||||
attributes: true,
|
||||
subtree: false,
|
||||
attributeFilter: ["inert"],
|
||||
});
|
||||
|
||||
// Additionally, Headless UI adds inert to previous portal containers that are
|
||||
// siblings of the one just created. We strip inert from ANY portal container
|
||||
// so long as background interaction is requested.
|
||||
const stripInertFromPortals = () => {
|
||||
const portals = document.querySelectorAll(
|
||||
"[data-headlessui-portal][inert]"
|
||||
);
|
||||
portals.forEach((el) => el.removeAttribute("inert"));
|
||||
};
|
||||
|
||||
stripInertFromPortals();
|
||||
|
||||
const globalObserver = new MutationObserver((mutList) => {
|
||||
mutList.forEach((m) => {
|
||||
if (
|
||||
m.type === "attributes" &&
|
||||
m.attributeName === "inert" &&
|
||||
(m.target as HTMLElement).hasAttribute("inert")
|
||||
) {
|
||||
const target = m.target as HTMLElement;
|
||||
if (target.hasAttribute("data-headlessui-portal")) {
|
||||
stripInert(target as HTMLElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
globalObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["inert"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
globalObserver.disconnect();
|
||||
};
|
||||
}, [allowBackgroundInteraction]);
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-[70]"
|
||||
onClose={onClose}
|
||||
onClose={closeOnOutsideClick ? onClose : () => null}
|
||||
static={allowBackgroundInteraction}
|
||||
>
|
||||
<div
|
||||
@@ -424,7 +764,10 @@ export const Modal = ({
|
||||
{/* Backdrop always rendered first in stacking context */}
|
||||
{!allowBackgroundInteraction && (
|
||||
<DialogBackdrop
|
||||
className={backdropClassName}
|
||||
className={twMerge(
|
||||
allowBackgroundInteraction ? "pointer-events-none" : "",
|
||||
backdropClassName
|
||||
)}
|
||||
onClose={onClose}
|
||||
closeOnOutsideClick={closeOnOutsideClick}
|
||||
disableHotkeyInput={disableHotkeyInput}
|
||||
@@ -438,7 +781,14 @@ export const Modal = ({
|
||||
/>
|
||||
)}
|
||||
<ModalExpandContext.Provider value={{ expanded, toggleExpanded }}>
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<div
|
||||
className="flex min-h-full items-center justify-center p-4 text-center"
|
||||
style={
|
||||
allowBackgroundInteraction
|
||||
? { pointerEvents: "none" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TransitionChild
|
||||
as="div"
|
||||
enter="ease-out duration-200"
|
||||
@@ -451,7 +801,9 @@ export const Modal = ({
|
||||
"w-full max-w-lg transform rounded-xl relative border border-ui-panel-border bg-[#2C2C2C] text-left align-middle shadow-2xl z-[70]",
|
||||
childPadding && !expanded ? "p-4" : "",
|
||||
className,
|
||||
dragging && !expanded ? "!transition-none" : "transition-all",
|
||||
(dragging || resizing) && !expanded
|
||||
? "!transition-none"
|
||||
: "transition-all",
|
||||
expanded &&
|
||||
"w-screen h-screen max-w-screen max-h-screen rounded-none"
|
||||
)}
|
||||
@@ -495,9 +847,11 @@ export const Modal = ({
|
||||
</DialogTitle>
|
||||
)}
|
||||
<div className={`h-full`.trim()}>{enhancedChildren}</div>
|
||||
{/* resize handles inside panel so clicks don't count as outside */}
|
||||
{renderResizeHandles()}
|
||||
</DialogPanel>
|
||||
{showClose && (
|
||||
<div className="absolute top-0 right-0 p-2.5">
|
||||
<div className="absolute top-0 right-0 m-2.5 z-[80]">
|
||||
<CloseButton onClick={onClose} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user