From 10603f7c8607c201f1df82a8a59de61ceb89b56b Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 19 Jun 2025 00:41:21 +0700 Subject: [PATCH] feature/lightbox drag (#677) - draggable and resizable lightbox Bug found: if lightbox is open, gallery modal cannot open the filter dropdown until lightbox is closed. --- .../gallery-modal/src/lib/gallery-modal.tsx | 71 ++-- .../lightbox-modal/src/lib/lightbox-modal.tsx | 274 ++++++------- .../components/modal/src/lib/modal.module.css | 2 +- .../libs/components/modal/src/lib/modal.tsx | 388 +++++++++++++++++- 4 files changed, 548 insertions(+), 187 deletions(-) diff --git a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx index 307356d502..c65be581d4 100644 --- a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx +++ b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx @@ -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" && ( @@ -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; diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx index 03bc642558..c4756e7938 100644 --- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx +++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx @@ -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 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( - -
- {/* backdrop */} - -
- - {/* dialog container */} -
- -
e.stopPropagation()} - > - {/* close */} - + const [mediaLoaded, setMediaLoaded] = React.useState(false); - {/* content grid */} -
- {/* image panel */} -
- {!imageUrl ? ( -
- Image not available -
- ) : mediaClass === "video" ? ( - - ) : ( - {imageAlt} - )} -
+ // Reset when imageUrl changes + React.useEffect(() => { + setMediaLoaded(false); + }, [imageUrl]); - {/* info + actions */} -
-
-
- {title || "Image Generation"} -
- {createdAt && ( -
- Created:{" "} - {dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")} -
- )} - {additionalInfo} -
+ return ( + + {/* Invisible drag handle strip at the very top for moving */} + +
+ - {/* buttons with spacing */} - {(onAddToSceneClicked && downloadUrl) || downloadUrl ? ( -
- - - {onAddToSceneClicked && downloadUrl && ( - - )} - - {downloadUrl && - (onDownloadClicked ? ( - - ) : ( - e.stopPropagation()} - className="no-underline" - > - - - ))} -
- ) : null} -
-
+ {/* content grid */} +
+ {/* image panel */} +
+ {!imageUrl ? ( +
+ Image not available
- + ) : mediaClass === "video" ? ( + + ) : ( + {imageAlt} setMediaLoaded(true)} + /> + )} + + {!mediaLoaded && imageUrl && ( +
+ +
+ )} +
+ + {/* info + actions */} +
+
+
+ {title || "Image Generation"} +
+ {createdAt && ( +
+ Created: {dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")} +
+ )} + {additionalInfo} +
+ + {/* buttons with spacing */} + {(onAddToSceneClicked && downloadUrl) || downloadUrl ? ( +
+ + + {onAddToSceneClicked && downloadUrl && ( + + )} + + {downloadUrl && + (onDownloadClicked ? ( + + ) : ( + e.stopPropagation()} + className="no-underline" + > + + + ))} +
+ ) : null}
- , - document.body + ); } diff --git a/frontend/libs/components/modal/src/lib/modal.module.css b/frontend/libs/components/modal/src/lib/modal.module.css index d0246c74b9..45c2aa47e9 100644 --- a/frontend/libs/components/modal/src/lib/modal.module.css +++ b/frontend/libs/components/modal/src/lib/modal.module.css @@ -4,4 +4,4 @@ * e.g. * .container { * } -*/ \ No newline at end of file +*/ diff --git a/frontend/libs/components/modal/src/lib/modal.tsx b/frontend/libs/components/modal/src/lib/modal.tsx index 7cb800aabd..251fa0938f 100644 --- a/frontend/libs/components/modal/src/lib/modal.tsx +++ b/frontend/libs/components/modal/src/lib/modal.tsx @@ -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(null); const positionRef = useRef<{ x: number; y: number } | null>(null); const lastPositionRef = useRef<{ x: number; y: number } | null>(null); + const [zIndex, setZIndex] = useState(() => ++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(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 = { + 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) => ( +
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 ( null} static={allowBackgroundInteraction} >
)} -
+
)}
{enhancedChildren}
+ {/* resize handles inside panel so clicks don't count as outside */} + {renderResizeHandles()} {showClose && ( -
+
)}