Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/studio/features/history/gallery-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const GalleryView = React.memo(function GalleryView({
initialPage,
}: GalleryViewProps) {
return (
<div className="h-full bg-card/50 backdrop-blur-sm border-l border-border/50">
<div className="h-full min-h-0 flex flex-col bg-card/50 backdrop-blur-sm border-l border-border/50">
<PersistentImageGallery
activeImageId={activeImageId}
onSelectImage={onSelectImage}
Expand All @@ -55,4 +55,4 @@ export const GalleryView = React.memo(function GalleryView({
/>
</div>
)
})
})
14 changes: 14 additions & 0 deletions components/studio/gallery/image-gallery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,20 @@ describe("ImageGallery", () => {
expect(screen.queryByTestId("gallery-empty")).not.toBeInTheDocument();
});

it("renders a scrollable mobile container with vaul drag disabled", () => {
vi.mocked(useIsMobile).mockReturnValue(true);

render(<ImageGallery images={mockImages} />);

const scrollContainer = screen.getByTestId(
"gallery-scroll-container-mobile",
);
expect(scrollContainer).toBeInTheDocument();
expect(scrollContainer).toHaveClass("overflow-y-auto");
expect(scrollContainer).toHaveAttribute("data-vaul-no-drag");
});


it("shows loading state when isLoading is true", () => {
render(<ImageGallery images={[]} isLoading={true} />);

Expand Down
7 changes: 4 additions & 3 deletions components/studio/gallery/image-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export const ImageGallery = React.memo(function ImageGallery({
<div
className={cn(
"flex flex-col bg-background",
isMobile ? "min-h-0 w-full" : "h-full overflow-hidden",
isMobile ? "h-full min-h-0 w-full overflow-hidden" : "h-full overflow-hidden",
className,
)}
data-testid="image-gallery"
Expand Down Expand Up @@ -418,11 +418,12 @@ export const ImageGallery = React.memo(function ImageGallery({
</div>

{isMobile ? (
// Mobile: Render direct div to allow parent (Drawer) to handle scrolling
// Mobile: Use native scroll container for smooth touch + reliable virtualization
<div
className="flex-1 min-h-0"
className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y [-webkit-overflow-scrolling:touch]"
ref={scrollContainerRef}
data-testid="gallery-scroll-container-mobile"
data-vaul-no-drag
>
{renderGalleryContent()}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,17 @@ describe("VirtualizedGalleryGrid", () => {
it("uses 800px rootMargin on mobile for infinite scroll", async () => {
vi.useFakeTimers();
vi.mocked(useIsMobile).mockReturnValue(true);
const props = createDefaultProps();
render(
<VirtualizedGalleryGrid
{...createDefaultProps()}
{...props}
canLoadMore={true}
onLoadMore={vi.fn()}
isMobile={true}
/>
);
props.scrollContainerRef.current!.scrollTop = 1;
props.scrollContainerRef.current!.dispatchEvent(new Event("scroll"));

await act(async () => {
await vi.advanceTimersByTimeAsync(500);
Expand Down
74 changes: 29 additions & 45 deletions components/studio/gallery/virtualized-gallery-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,18 +131,31 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
// Get mobile drawer visibility state (undefined when not in a drawer)
const drawerState = useMobileDrawerVisibility();
const isDrawerVisible = drawerState?.isVisible ?? true; // Default to true for desktop
const [hasUserScrolled, setHasUserScrolled] = React.useState(false);

// Track when drawer becomes visible to add a small delay for animation
const prevDrawerVisibleRef = React.useRef(isDrawerVisible);
const [observerEnabled, setObserverEnabled] = React.useState(
!isMobile || isDrawerVisible
!isMobile || (isDrawerVisible && hasUserScrolled)
);

React.useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);

React.useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const onScroll = () => {
if (!hasUserScrolled && el.scrollTop > 0) {
setHasUserScrolled(true);
}
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [scrollContainerRef, hasUserScrolled]);

// Handle drawer visibility changes
React.useEffect(() => {
const wasVisible = prevDrawerVisibleRef.current;
Expand All @@ -153,7 +166,7 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
// Small delay to let drawer animation complete before enabling observer
// This prevents the sentinel from being detected during the animation
const timer = setTimeout(() => {
setObserverEnabled(true);
setObserverEnabled(hasUserScrolled);
}, 250); // Animation reduced to 200ms + 50ms buffer

return () => clearTimeout(timer);
Expand All @@ -169,8 +182,12 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
setObserverEnabled(true);
}

if (isMobile && isNowVisible && hasUserScrolled) {
setObserverEnabled(true);
}

prevDrawerVisibleRef.current = isNowVisible;
}, [isDrawerVisible, isMobile]);
}, [isDrawerVisible, isMobile, hasUserScrolled]);

// Infinite scroll: trigger loadMore when sentinel becomes visible
React.useEffect(() => {
Expand All @@ -189,23 +206,6 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
return;
}

// On mobile in a drawer, the actual scroll container is the drawer's container, not our scrollContainerRef
// The drawer has its own scroll container that wraps the gallery
// We need to find it by traversing up the DOM
let actualScrollContainer = scrollContainer;
if (isMobile && drawerState) {
// Look for the drawer's scroll container (has overflow-y-auto and is a parent of our container)
let parent = scrollContainer.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
if (styles.overflowY === "auto" || styles.overflowY === "scroll") {
actualScrollContainer = parent as HTMLDivElement;
break;
}
parent = parent.parentElement;
}
}

// Debounce to prevent rapid-fire requests
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedLoadMore = () => {
Expand All @@ -225,18 +225,14 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
// This prevents automatic fetching when drawer opens (scrollTop is 0)
// The IntersectionObserver fires immediately on observe() if target is visible,
// which can happen during drawer animation before layout stabilizes
if (
isMobile &&
actualScrollContainer &&
actualScrollContainer.scrollTop === 0
) {
if (isMobile && drawerState && !hasUserScrolled) {
return;
}
debouncedLoadMore();
}
},
{
root: actualScrollContainer,
root: scrollContainer,
// Desktop & Mobile: 800px look-ahead for aggressive pre-fetching
// This triggers loading well before the user reaches the bottom
rootMargin: "0px 0px 800px 0px",
Expand All @@ -259,37 +255,24 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
isMobile,
drawerState,
scrollContainerRef,
hasUserScrolled,
]);

// Failsafe: Check if we need to load more immediately after a load finishes
// This handles cases where the observer might not re-fire (e.g. if we are already intersecting)
React.useEffect(() => {
if (!isLoadingMore && canLoadMore && isMounted && onLoadMore) {
if (isMobile && drawerState && !hasUserScrolled) {
return;
}
// Small delay to allow layout to settle
const timer = setTimeout(() => {
const sentinel = sentinelRef.current;
const scrollContainer = scrollContainerRef.current;

if (sentinel && scrollContainer) {
// Determine the actual scroll container (handling mobile drawer case)
let actualScrollContainer = scrollContainer;
if (isMobile && drawerState) {
let parent = scrollContainer.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
if (
styles.overflowY === "auto" ||
styles.overflowY === "scroll"
) {
actualScrollContainer = parent as HTMLDivElement;
break;
}
parent = parent.parentElement;
}
}

const sentinelRect = sentinel.getBoundingClientRect();
const containerRect = actualScrollContainer.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
const rootMargin = 800; // Match the 800px margin in IntersectionObserver

// If sentinel top is above (container bottom + margin)
Expand All @@ -300,7 +283,7 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
sentinelRect.bottom >= containerRect.top
) {
// On mobile, also check scrollTop to respect the "don't load on open" rule
if (isMobile && actualScrollContainer.scrollTop === 0) {
if (isMobile && drawerState && scrollContainer.scrollTop === 0) {
return;
}
onLoadMore();
Expand All @@ -317,6 +300,7 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid
isMobile,
drawerState,
scrollContainerRef,
hasUserScrolled,
]);

return (
Expand Down
11 changes: 10 additions & 1 deletion components/studio/mobile/mobile-editor-drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ describe("MobileEditorDrawer", () => {
})
})

describe("Drawer Configuration", () => {
it("constrains drawer height for scrollable content", () => {
render(<MobileEditorDrawer {...defaultProps} />)

const content = screen.getByTestId("mobile-editor-drawer")
expect(content).toHaveClass("h-[85dvh]")
expect(content).toHaveClass("max-h-[85dvh]")
})
})

/**
* Snap Point Behavior Tests
*
Expand Down Expand Up @@ -178,4 +188,3 @@ describe("MobileEditorDrawer", () => {
})
})
})

2 changes: 1 addition & 1 deletion components/studio/mobile/mobile-editor-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function MobileEditorDrawer({
<DrawerContent
className={cn(
// Override max-height to use dynamic viewport height
"max-h-[85dvh]",
"h-[85dvh] max-h-[85dvh]",
// Lower z-index (z-40) to allow popovers (z-50) to appear above
// Mobile-specific component doesn't need to compete with global overlays
"!z-40",
Expand Down
8 changes: 8 additions & 0 deletions components/studio/mobile/mobile-history-drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ describe("MobileHistoryDrawer", () => {
expect(content).toHaveClass("bg-card/95");
expect(content).toHaveClass("backdrop-blur-xl");
});

it("constrains drawer height for scrollable content", () => {
render(<MobileHistoryDrawer {...defaultProps} />);

const content = screen.getByTestId("mobile-history-drawer");
expect(content).toHaveClass("h-[85dvh]");
expect(content).toHaveClass("max-h-[85dvh]");
});
});

describe("Custom className", () => {
Expand Down
5 changes: 2 additions & 3 deletions components/studio/mobile/mobile-history-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function MobileHistoryDrawer({
<DrawerContent
className={cn(
// Override max-height to use dynamic viewport height
"max-h-[85dvh]",
"h-[85dvh] max-h-[85dvh]",
// Dark glass effect matching studio theme
"bg-card/95 backdrop-blur-xl",
// Subtle border for depth
Expand All @@ -135,8 +135,7 @@ export function MobileHistoryDrawer({
</DrawerTitle>
</DrawerHeader>

{/* Scrollable content area - native scroll for better touch handling */}
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col overscroll-contain">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{/* Provide drawer visibility to children for infinite scroll control */}
<MobileDrawerVisibilityContext.Provider
value={visibilityContextValue}
Expand Down
20 changes: 19 additions & 1 deletion hooks/use-studio-ui.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useStudioUI } from "./use-studio-ui";
import { createMockImage } from "@/lib/test-utils";

Expand Down Expand Up @@ -202,6 +202,24 @@ describe("useStudioUI", () => {
});
});

describe("SSR hydration mismatch behavior", () => {
it("closes drawers when isMobile flips to true after mount", async () => {
mockUseIsMobile.mockReturnValue(false);
const { result, rerender } = renderHook(() => useStudioUI());

expect(result.current.showLeftSidebar).toBe(true);
expect(result.current.showGallery).toBe(true);

mockUseIsMobile.mockReturnValue(true);
rerender();

await waitFor(() => {
expect(result.current.showLeftSidebar).toBe(false);
expect(result.current.showGallery).toBe(false);
});
});
});

describe("Lightbox functionality", () => {
it("opens lightbox with image", () => {
const { result } = renderHook(() => useStudioUI());
Expand Down
11 changes: 3 additions & 8 deletions hooks/use-studio-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,11 @@ export function useStudioUI(): UseStudioUIReturn {
// Sync drawer state on mobile after hydration
// This runs once after the client-side isMobile value is determined
React.useEffect(() => {
// Only run once, on initial client-side mount
if (!isMobile) return;
if (hasInitializedMobileRef.current) return;
hasInitializedMobileRef.current = true;

// If we're on mobile, ensure drawers are closed
// This corrects the SSR mismatch where isMobile was false on server
if (isMobile) {
setShowLeftSidebar(false);
setShowGallery(false);
}
setShowLeftSidebar(false);
setShowGallery(false);
}, [isMobile]);

// ========================================
Expand Down