collective

Browse / github.com/storytold/artcraft / patch

fix(lightbox): remember image-set position during gallery navigation

Remember carousel/imageset index when navigating up or down (arrow keys)

Status
open
Score
0.00
Adopters
0 other operators
Author
channelramble (imported from pull request #2235, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+322 −40 in 3 files
Review
pending
Shared
2026-10-28
Id
0f7bc6abca3a88e7b10a3dab

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx netlify.toml

diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx--- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx+++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx@@ -1,12 +1,240 @@-import { render } from '@testing-library/react';+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";+import { useState, type ReactNode } from "react";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import LightboxModal from "./lightbox-modal"; -import LightboxModal from './lightbox-modal';+const mocks = vi.hoisted(() => {+  function carousel(selector: string) {+    const listeners = new Map<string, Set<() => void>>();+    const count = () => document.querySelectorAll(selector).length;+    const api = {+      index: 0,+      listeners,+      selectedScrollSnap: () => api.index,+      canScrollPrev: () => api.index > 0,+      canScrollNext: () => api.index < count() - 1,+      scrollPrev: () => api.scrollTo(api.index - 1),+      scrollNext: () => api.scrollTo(api.index + 1),+      scrollTo: (index: number) => {+        const next = Math.max(0, Math.min(index, count() - 1));+        if (next !== api.index) {+          api.index = next;+          api.emit("select");+        }+      },+      reInit: () => {+        api.scrollTo(api.index);+        api.emit("reInit");+      },+      on: (event: string, callback: () => void) => {+        if (!listeners.has(event)) listeners.set(event, new Set());+        listeners.get(event)!.add(callback);+        return api;+      },+      off: (event: string, callback: () => void) => {+        listeners.get(event)?.delete(callback);+        return api;+      },+      emit: (event: string) => {+        listeners.get(event)?.forEach((callback) => callback());+      },+    };+    return api;+  } -describe('LightboxModal', () => {-  -  it('should render successfully', () => {-    const { baseElement } = render(<LightboxModal />);-    expect(baseElement).toBeTruthy();+  return {+    main: carousel(".embla__slide"),+    thumbs: carousel(".embla-thumbs__slide"),+    getBatch: vi.fn(),+    getMedia: vi.fn().mockResolvedValue({ success: false }),+  };+});++vi.mock("embla-carousel-react", () => ({+  default: (options: { containScroll?: string }) => {+    const [node, setNode] = useState<HTMLElement | null>(null);+    return [setNode, node ? (options.containScroll ? mocks.thumbs : mocks.main) : undefined];+  },+}));+vi.mock("@storyteller/ui-modal", () => ({+  Modal: Object.assign(+    ({ isOpen, children }: { isOpen: boolean; children: ReactNode }) =>+      isOpen ? <div>{children}</div> : null,+    { DragHandle: ({ children }: { children: ReactNode }) => <>{children}</> },+  ),+}));+vi.mock("@storyteller/api", () => ({+  MediaFilesApi: class {+    GetMediaFilesByBatchToken = mocks.getBatch;+    GetMediaFileByToken = mocks.getMedia;+  },+  PromptsApi: class {},+}));+vi.mock("./tags/TagsSection", () => ({ TagsSection: () => null }));+vi.mock("@storyteller/ui-audio-player", () => ({ WaveformAudioPlayer: () => null }));+vi.mock("@storyteller/google-analytics", () => ({ gtagEvent: () => {} }));++const BUNDLES = [+  { imageUrl: "a0.png", imageUrls: ["a0.png", "a1.png", "a2.png"] },+  { imageUrl: "b0.png", imageUrls: ["b0.png", "b1.png", "b2.png"] },+];+const BASE_PROPS = {+  isOpen: true,+  onClose: () => {},+  onCloseGallery: () => {},+  mediaClass: "image",+};++beforeEach(() => {+  vi.useFakeTimers();+  mocks.main.index = 0;+  mocks.thumbs.index = 0;+  mocks.main.listeners.clear();+  mocks.thumbs.listeners.clear();+  mocks.getBatch.mockReset();+  mocks.getBatch.mockImplementation(({ batchToken }: { batchToken: string }) =>+    Promise.resolve({+      success: true,+      data: [0, 1, 2].map((index) => ({+        token: `${batchToken}${index}`,+        media_links: { cdn_url: `${batchToken}${index}.png` },+      })),+    }),+  );+});++afterEach(() => {+  cleanup();+  vi.useRealTimers();+});++describe("LightboxModal carousel navigation", () => {+  it("remembers each bundle's last slide when navigating down and back up", () => {+    renderNavigator(BUNDLES);+    selectThumbnail(3);+    fireEvent.keyDown(window, { key: "ArrowDown" });+    expectSelection(0);+    selectThumbnail(2);++    fireEvent.keyDown(window, { key: "ArrowUp" });+    expectSelection(2);+    fireEvent.keyDown(window, { key: "ArrowDown" });+    expectSelection(1);+    expect(mocks.main.listeners.get("select")?.size).toBe(1);+    expect(mocks.main.listeners.get("reInit")?.size).toBe(1);+  });++  it("keeps cross-bundle navigation at the left/right boundaries", () => {+    renderNavigator(BUNDLES);+    fireEvent.keyDown(window, { key: "ArrowRight" });+    expectSelection(1);+    fireEvent.keyDown(window, { key: "ArrowRight" });+    expectSelection(2);+    fireEvent.keyDown(window, { key: "ArrowRight" });+    expectSelection(0);+    expect(screen.getByAltText("Generated image 1").getAttribute("src")).toContain("b0.png");++    fireEvent.keyDown(window, { key: "ArrowLeft" });+    expectSelection(2);+    expect(screen.getByAltText("Generated image 1").getAttribute("src")).toContain("a0.png");+  });++  it.each(["video", "audio"])("restores an image selection after visiting %s media", (mediaClass) => {+    renderNavigator([BUNDLES[0], { imageUrl: "other-media", mediaClass }]);+    selectThumbnail(3);+    fireEvent.keyDown(window, { key: "ArrowDown" });+    expect(screen.queryByAltText("Thumbnail 3")).toBeNull();+    expect(mocks.main.listeners.get("select")?.size).toBe(0);++    fireEvent.keyDown(window, { key: "ArrowUp" });+    expectSelection(2);+  });++  it("restores the saved slide after a fetched batch replaces its one-image placeholder", async () => {+    renderNavigator([+      { imageUrl: "a0.png", mediaId: "a0", batchImageToken: "a" },+      { imageUrl: "b0.png", mediaId: "b0", batchImageToken: "b" },+    ]);+    await loadBatch();+    selectThumbnail(3);+    fireEvent.keyDown(window, { key: "ArrowDown" });+    expect(screen.queryByAltText("Thumbnail 3")).toBeNull();+    expect(screen.getByAltText("Generated image 1").getAttribute("src")).toContain("b0.png");++    fireEvent.keyDown(window, { key: "ArrowUp" });+    await loadBatch();+    expectSelection(2);+    act(() => mocks.main.reInit());+    expectSelection(2);+  });++  it("honors a different opening image when a fetched batch is reordered", async () => {+    const onDownloadClicked = vi.fn();+    const props = { ...BASE_PROPS, batchImageToken: "a", onDownloadClicked };+    const view = render(<LightboxModal {...props} mediaId="a0" imageUrl="a0.png" />);+    await loadBatch();+    selectThumbnail(3);++    view.rerender(<LightboxModal {...props} mediaId="a1" imageUrl="a1.png" />);+    await loadBatch();+    expectSelection(0);+    fireEvent.click(screen.getByRole("button", { name: "Download" }));+    expect(onDownloadClicked).toHaveBeenCalledWith("a1.png", "image", undefined);++    view.rerender(<LightboxModal {...props} mediaId="a0" imageUrl="a0.png" />);+    await loadBatch();+    expectSelection(2);+  });++  it("uses the requested initial slide for a new entry and after reopening", () => {+    const props = { ...BASE_PROPS, ...BUNDLES[0], initialIndex: 1 };+    const view = render(<LightboxModal {...props} />);+    expectSelection(1);+    selectThumbnail(3);++    view.rerender(<LightboxModal {...props} isOpen={false} />);+    view.rerender(<LightboxModal {...props} />);+    expectSelection(1);+    view.rerender(<LightboxModal {...props} initialIndex={0} />);+    expectSelection(0);++    view.unmount();+    expect(mocks.main.listeners.get("select")?.size).toBe(0);+    expect(mocks.main.listeners.get("reInit")?.size).toBe(0);   });-   });++function renderNavigator(bundles: Array<{+  imageUrl: string;+  imageUrls?: string[];+  mediaId?: string;+  batchImageToken?: string;+  mediaClass?: string;+}>) {+  function Navigator() {+    const [index, setIndex] = useState(0);+    return (+      <LightboxModal+        {...BASE_PROPS}+        {...bundles[index]}+        onNavigatePrev={() => setIndex((current) => Math.max(0, current - 1))}+        onNavigateNext={() => setIndex((current) => Math.min(bundles.length - 1, current + 1))}+      />+    );+  }+  return render(<Navigator />);+}++function selectThumbnail(number: number) {+  fireEvent.click(screen.getByAltText(`Thumbnail ${number}`).closest("button")!);+}++function expectSelection(index: number) {+  const thumbnail = screen.getByAltText(`Thumbnail ${index + 1}`).closest("button")!;+  expect(thumbnail.classList.contains("border-white")).toBe(true);+  expect(mocks.main.selectedScrollSnap()).toBe(index);+}++async function loadBatch() {+  await act(async () => vi.advanceTimersByTimeAsync(200));+}diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx--- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx+++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx@@ -150,8 +150,17 @@ export function LightboxModal({   const [isPromptClamped, setIsPromptClamped] = useState<boolean>(false);   const [promptCopied, setPromptCopied] = useState<boolean>(false);   const promptCopiedTimeoutRef = useRef<number | null>(null);-  const [batchImages, setBatchImages] = useState<string[] | null>(null);-  const [batchTokens, setBatchTokens] = useState<string[] | null>(null);+  const [batch, setBatch] = useState<{+    token: string;+    images: string[];+    tokens: string[];+    loading: boolean;+  } | null>(null);+  const batchImages = batch && batch.token === batchImageToken ? batch.images : null;+  const batchTokens = batch && batch.token === batchImageToken ? batch.tokens : null;+  const isBatchLoading = Boolean(+    batchImageToken && (!batch || batch.token !== batchImageToken || batch.loading),+  );   const [creator, setCreator] = useState<UserInfo | null>(null);   const [shareCopied, setShareCopied] = useState<boolean>(false);   const shareCopiedTimeoutRef = useRef<number | null>(null);@@ -193,7 +202,6 @@ export function LightboxModal({   useEffect(() => {     if (isOpen) {       setRefPreviewUrl(null);-      setSelectedIndex(0);       setMediaLoaded(false);       setMediaWidth(null);       setMediaHeight(null);@@ -297,14 +305,12 @@ export function LightboxModal({   // Fetch batch images — debounced + cancellable like fetchPrompt above.   useEffect(() => {     if (!batchImageToken) {-      setBatchImages(null);-      setBatchTokens(null);+      setBatch(null);       return;     }      // Clear stale batch data immediately-    setBatchImages(null);-    setBatchTokens(null);+    setBatch({ token: batchImageToken, images: [], tokens: [], loading: true });      let cancelled = false; @@ -338,20 +344,21 @@ export function LightboxModal({               return 0;             }); -            setBatchImages(sortedItems.map((item) => item.url));-            setBatchTokens(sortedItems.map((item) => item.token));+            setBatch({+              token: batchImageToken,+              images: sortedItems.map((item) => item.url),+              tokens: sortedItems.map((item) => item.token),+              loading: false,+            });           } else {-            setBatchImages(null);-            setBatchTokens(null);+            setBatch({ token: batchImageToken, images: [], tokens: [], loading: false });           }         } else {-          setBatchImages(null);-          setBatchTokens(null);+          setBatch({ token: batchImageToken, images: [], tokens: [], loading: false });         }       } catch (error: unknown) {         if (cancelled) return;-        setBatchImages(null);-        setBatchTokens(null);+        setBatch({ token: batchImageToken, images: [], tokens: [], loading: false });       }     }, 200); @@ -372,6 +379,14 @@ export function LightboxModal({   }, [batchImages, imageUrls, imageUrl]);    const [selectedIndex, setSelectedIndex] = useState(0);+  // A different opening image can reorder a fetched batch; treat it as a new entry point.+  const carouselKey = JSON.stringify([+    batchImageToken,+    mediaId ?? imageUrl ?? imageUrls?.[0],+    initialIndex,+  ]);+  const rememberedIndices = useRef(new Map<string, number>());+  const restoringSelection = useRef(false);   const carouselOptions: EmblaOptionsType = useMemo(     () => ({ loop: false }),     [],@@ -418,30 +433,62 @@ export function LightboxModal({   );    const onSelect = useCallback(() => {-    if (!emblaMainApi || !emblaThumbsApi) return;+    if (!emblaMainApi || !isOpen || restoringSelection.current) return;     const index = emblaMainApi.selectedScrollSnap();     setSelectedIndex(index);-    emblaThumbsApi.scrollTo(index);-  }, [emblaMainApi, emblaThumbsApi]);+    emblaThumbsApi?.scrollTo(index);+    if (!isBatchLoading) rememberedIndices.current.set(carouselKey, index);+  }, [emblaMainApi, emblaThumbsApi, isOpen, isBatchLoading, carouselKey]); -  useEffect(() => {-    if (!emblaMainApi) return;-    onSelect();-    emblaMainApi.on("select", onSelect).on("reInit", onSelect);-  }, [emblaMainApi, onSelect]);+  const restoreSelection = useCallback(() => {+    if (!isOpen) return;+    const rememberedIndex = rememberedIndices.current.get(carouselKey);+    const index = Math.max(+      0,+      Math.min(rememberedIndex ?? initialIndex ?? 0, effectiveImageUrls.length - 1),+    );+    const wasRestoring = restoringSelection.current;+    restoringSelection.current = true;+    try {+      setSelectedIndex(index);+      emblaMainApi?.scrollTo(index, true);+      emblaThumbsApi?.scrollTo(index, true);+    } finally {+      restoringSelection.current = wasRestoring;+    }+  }, [+    isOpen,+    carouselKey,+    initialIndex,+    effectiveImageUrls.length,+    emblaMainApi,+    emblaThumbsApi,+  ]); -  // Re-initialize Embla when slides change so it picks up the new DOM children   useEffect(() => {-    emblaMainApi?.reInit();-    emblaThumbsApi?.reInit();-  }, [effectiveImageUrls, emblaMainApi, emblaThumbsApi]);+    if (!emblaMainApi) return;+    emblaMainApi.on("select", onSelect).on("reInit", restoreSelection);+    return () => {+      emblaMainApi.off("select", onSelect).off("reInit", restoreSelection);+    };+  }, [emblaMainApi, onSelect, restoreSelection]); +  // Restore each bundle's last selection after navigation or delayed batch loading.   useEffect(() => {-    const idx = initialIndex ?? 0;-    setSelectedIndex(idx);-    emblaMainApi?.scrollTo(idx, true);-    emblaThumbsApi?.scrollTo(idx, true);-  }, [batchImageToken, imageUrl, emblaMainApi, emblaThumbsApi]);+    if (!isOpen) {+      rememberedIndices.current.clear();+      setSelectedIndex(0);+      return;+    }+    restoringSelection.current = true;+    try {+      emblaMainApi?.reInit();+      emblaThumbsApi?.reInit();+      restoreSelection();+    } finally {+      restoringSelection.current = false;+    }+  }, [isOpen, effectiveImageUrls, emblaMainApi, emblaThumbsApi, restoreSelection]);    const selectedImageUrl = effectiveImageUrls[selectedIndex] ?? null;   const actionUrl =diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+  ignore = "exit 0"