diff --git a/react-front-end/__mocks__/GallerySearchModule.mock.ts b/react-front-end/__mocks__/GallerySearchModule.mock.ts index 31ce13850f..d09d744ab4 100644 --- a/react-front-end/__mocks__/GallerySearchModule.mock.ts +++ b/react-front-end/__mocks__/GallerySearchModule.mock.ts @@ -476,6 +476,7 @@ export const transformedBasicImageSearchResponse: OEQ.Search.SearchResult ({ describe("", () => { const renderGallery = () => - render(); + render(); // 16 entries in total. it("displays the lightbox when the image is clicked on", () => { const { getAllByLabelText, queryAllByLabelText } = renderGallery(); @@ -62,4 +63,24 @@ describe("", () => { expect(mockUseHistoryPush).toHaveBeenCalled(); expect(mockUseHistoryPush.mock.calls[0][0].match("/items/")).toBeTruthy(); }); + + describe("viewing gallery entries in a loop", () => { + it.each([ + ["first", "next", "last", 15, viewNext], + ["last", "previous", "first", 0, viewPrevious], + ])( + "shows the %s entry when navigate to the %s entry of the %s entry", + ( + currentPosition: string, + direction: string, + newPosition: string, + index: number, + arrowButtonLabel: string + ) => { + const { getAllByLabelText, queryByLabelText } = renderGallery(); + userEvent.click(getAllByLabelText(ariaLabel)[index]); + expect(queryByLabelText(arrowButtonLabel)).toBeInTheDocument(); + } + ); + }); }); diff --git a/react-front-end/__tests__/tsrc/search/components/GallerySearchResultHelpers.ts b/react-front-end/__tests__/tsrc/search/components/GallerySearchResultHelpers.ts index 445046ef87..5c738a9374 100644 --- a/react-front-end/__tests__/tsrc/search/components/GallerySearchResultHelpers.ts +++ b/react-front-end/__tests__/tsrc/search/components/GallerySearchResultHelpers.ts @@ -23,6 +23,7 @@ import { } from "../../../../tsrc/modules/GallerySearchModule"; const buildGalleryEntry = (name: string): GalleryEntry => ({ + id: "40e879db-393b-4256-bfe2-9a78771d6437", mimeType: "image/png", name, thumbnailSmall: "./placeholder-135x135.png", diff --git a/react-front-end/tsrc/components/Lightbox.tsx b/react-front-end/tsrc/components/Lightbox.tsx index 21aaf2b13f..6864f1b192 100644 --- a/react-front-end/tsrc/components/Lightbox.tsx +++ b/react-front-end/tsrc/components/Lightbox.tsx @@ -33,13 +33,7 @@ import OpenInNewIcon from "@material-ui/icons/OpenInNew"; import { pipe } from "fp-ts/function"; import * as O from "fp-ts/Option"; import * as React from "react"; -import { - ReactNode, - SyntheticEvent, - useCallback, - useEffect, - useState, -} from "react"; +import { ReactNode, SyntheticEvent, useEffect, useState } from "react"; import { Literal, match, Unknown } from "runtypes"; import { CustomMimeTypes, @@ -140,22 +134,17 @@ const Lightbox = ({ open, onClose, config }: LightboxProps) => { const [lightBoxConfig, setLightBoxConfig] = useState(config); const { src, title, mimeType, onPrevious, onNext } = lightBoxConfig; - const handleOnPrevious = useCallback(() => { - setContent(undefined); - onPrevious && setLightBoxConfig(onPrevious()); - }, [onPrevious]); - - const handleOnNext = useCallback(() => { + const handleNav = (getLightboxConfig: () => LightboxConfig) => { setContent(undefined); - onNext && setLightBoxConfig(onNext()); - }, [onNext]); + setLightBoxConfig(getLightboxConfig()); + }; useEffect(() => { const keyDownHandler = (e: KeyboardEvent) => { - if (e.key === "ArrowLeft") { - handleOnPrevious(); - } else if (e.key === "ArrowRight") { - handleOnNext(); + if (onPrevious && e.key === "ArrowLeft") { + handleNav(onPrevious); + } else if (onNext && e.key === "ArrowRight") { + handleNav(onNext); } else if (e.key === "Escape") { onClose(); } @@ -164,7 +153,7 @@ const Lightbox = ({ open, onClose, config }: LightboxProps) => { return () => { window.removeEventListener("keydown", keyDownHandler); }; - }, [handleOnPrevious, handleOnNext, onClose]); + }, [onPrevious, onNext, onClose]); // Update content when config is updated. useEffect(() => { @@ -298,7 +287,7 @@ const Lightbox = ({ open, onClose, config }: LightboxProps) => { title={viewPreviousString} onClick={(e) => { e.stopPropagation(); - handleOnPrevious(); + handleNav(onPrevious); }} > @@ -315,7 +304,7 @@ const Lightbox = ({ open, onClose, config }: LightboxProps) => { title={viewNextString} onClick={(e) => { e.stopPropagation(); - handleOnNext(); + handleNav(onNext); }} > diff --git a/react-front-end/tsrc/modules/GallerySearchModule.ts b/react-front-end/tsrc/modules/GallerySearchModule.ts index c997aef010..514ba640b1 100644 --- a/react-front-end/tsrc/modules/GallerySearchModule.ts +++ b/react-front-end/tsrc/modules/GallerySearchModule.ts @@ -37,6 +37,10 @@ import * as yt from "./YouTubeModule"; * the asset and the associated MIME Type. */ export interface GalleryEntry { + /** + * The ID of the attachment this points to. + */ + id: string; /** * The MIME type for the attachment being represented. Note however that it really only matches * the MIME type for `imagePathFull` - as thumbnails generated for small and medium seem to mainly @@ -243,6 +247,7 @@ export const buildGalleryEntry = ( Do(E.either) .do(validateAttachmentType(attachment)) .do(validateThumbnailRequirements(attachment)) + .bind("id", E.right(attachment.id)) .bind("mimeType", mimeType(attachment)) .bind("name", E.right(attachment.description ?? attachment.id)) .bind("thumbnailSmall", thumbnailLink(attachment, "small")) diff --git a/react-front-end/tsrc/modules/ViewerModule.ts b/react-front-end/tsrc/modules/ViewerModule.ts index 12b924c2c3..57eb354373 100644 --- a/react-front-end/tsrc/modules/ViewerModule.ts +++ b/react-front-end/tsrc/modules/ViewerModule.ts @@ -50,6 +50,20 @@ export interface ViewerLightboxConfig { config: LightboxConfig; } +/** + * Represent either an Attachment or a GalleryEntry to be displayed in the Lightbox. + */ +export interface LightboxEntry { + /** ID of the entry. */ + id: string; + /** URL for the item to display in the Lightbox. */ + src: string; + /** Title of the entry displayed at the top of the Lightbox. */ + title?: string; + /** MIME type of the entry specified by `src` */ + mimeType: string; +} + export type ViewerConfig = ViewerLinkConfig | ViewerLightboxConfig; export const isViewerLightboxConfig = ( @@ -187,39 +201,50 @@ export const getViewerDefinitionForAttachment = ( }; /** - * Build a function to handler navigation between Lightbox attachments. - * @param lightboxAttachments All attachments that can be viewed in Lightbox and their Viewer definitions. - * @param attachmentIndex Index of the attachment to be viewed + * Build a function to handle navigation between Lightbox entries. + * @param entries A list of Attachment or GalleryEntry that can be viewed in Lightbox. + * @param entryIndex Index of the current lightbox entry. + * @param isLoopBack `true` to make the navigation looping. */ export const buildLightboxNavigationHandler = ( - lightboxAttachments: AttachmentAndViewerDefinition[], - attachmentIndex: number -): (() => LightboxConfig) | undefined => - pipe( - lightboxAttachments, - A.lookup(attachmentIndex), + entries: LightboxEntry[], + entryIndex: number, + isLoopBack = false +): (() => LightboxConfig) | undefined => { + const buildIndexForLoop = (index: number, start: number, end: number) => { + if (index < start) { + return end; + } else if (index > end) { + return start; + } + return index; + }; + const index = isLoopBack + ? buildIndexForLoop(entryIndex, 0, entries.length - 1) + : entryIndex; + + return pipe( + entries, + A.lookup(index), O.fold( () => undefined, - ({ - attachment: { description, mimeType }, - viewerDefinition: [viewer, viewUrl], - }) => { - if (viewer === "lightbox") { - return () => ({ - src: viewUrl, - title: description, - mimeType: mimeType ?? "", - onNext: buildLightboxNavigationHandler( - lightboxAttachments, - attachmentIndex + 1 - ), - onPrevious: buildLightboxNavigationHandler( - lightboxAttachments, - attachmentIndex - 1 - ), - }); - } - throw new TypeError(`Unexpected viewer configuration: ${viewer}`); + ({ title, mimeType, src }) => { + return () => ({ + src, + title, + mimeType: mimeType ?? "", + onNext: buildLightboxNavigationHandler( + entries, + index + 1, + isLoopBack + ), + onPrevious: buildLightboxNavigationHandler( + entries, + index - 1, + isLoopBack + ), + }); } ) ); +}; diff --git a/react-front-end/tsrc/search/components/GallerySearchResult.tsx b/react-front-end/tsrc/search/components/GallerySearchResult.tsx index afca8144e6..dc77282e47 100644 --- a/react-front-end/tsrc/search/components/GallerySearchResult.tsx +++ b/react-front-end/tsrc/search/components/GallerySearchResult.tsx @@ -32,6 +32,10 @@ import { buildSelectionSessionItemSummaryLink, isSelectionSessionOpen, } from "../../modules/LegacySelectionSessionModule"; +import { + buildLightboxNavigationHandler, + LightboxEntry, +} from "../../modules/ViewerModule"; import { languageStrings } from "../../util/langstrings"; const { ariaLabel, viewItem } = languageStrings.searchpage.gallerySearchResult; @@ -96,6 +100,50 @@ const GallerySearchResult = ({ items }: GallerySearchResultProps) => { ); + // A list of LightboxEntry which includes all main entries and additional entries. + const lightboxEntries: LightboxEntry[] = items.flatMap( + ({ mainEntry, additionalEntries }) => + [mainEntry, ...additionalEntries].map( + ({ id, name, mimeType, directUrl }) => ({ + src: directUrl, + title: name, + mimeType: mimeType, + id, + }) + ) + ); + + const buildOnClickHandler = ({ + mimeType, + directUrl: src, + name, + id, + }: GalleryEntry) => () => { + const initialLightboxEntryIndex = lightboxEntries.findIndex( + (entry) => entry.id === id + ); + + setLightboxProps({ + onClose: () => setLightboxProps(undefined), + open: true, + config: { + src, + title: name, + mimeType, + onNext: buildLightboxNavigationHandler( + lightboxEntries, + initialLightboxEntryIndex + 1, + true + ), + onPrevious: buildLightboxNavigationHandler( + lightboxEntries, + initialLightboxEntryIndex - 1, + true + ), + }, + }); + }; + const mapItemsToTiles = () => items.flatMap( ({ @@ -106,20 +154,6 @@ const GallerySearchResult = ({ items }: GallerySearchResultProps) => { version, }: GallerySearchResultItem) => { const itemName = name ?? uuid; - const buildOnClickHandler = ({ - mimeType, - directUrl: src, - name, - }: GalleryEntry) => () => - setLightboxProps({ - onClose: () => setLightboxProps(undefined), - open: true, - config: { - src, - title: name, - mimeType, - }, - }); return [ buildTile( diff --git a/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx b/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx index 4b04b68ed2..2d2d3c0c3c 100644 --- a/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx +++ b/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx @@ -55,6 +55,7 @@ import { AttachmentAndViewerDefinition, buildLightboxNavigationHandler, getViewerDefinitionForAttachment, + LightboxEntry, } from "../../modules/ViewerModule"; import { languageStrings } from "../../util/langstrings"; import { ResourceSelector } from "./ResourceSelector"; @@ -178,15 +179,25 @@ export const SearchResultAttachmentsList = ({ ) ); - const lightboxAttachments = attachmentsAndViewerDefinitions.filter( - ({ viewerDefinition: [viewer] }) => viewer === "lightbox" - ); + const lightboxEntries: LightboxEntry[] = attachmentsAndViewerDefinitions + .filter(({ viewerDefinition: [viewer] }) => viewer === "lightbox") + .map( + ({ + attachment: { id, description, mimeType }, + viewerDefinition: [_, src], + }) => ({ + src, + title: description, + mimeType: mimeType ?? "", + id, + }) + ); // Transform AttachmentAndViewerDefinition to AttachmentAndViewerConfig. const attachmentsAndConfigs: AttachmentAndViewerConfig[] = attachmentsAndViewerDefinitions.map( ({ viewerDefinition: [viewer, viewUrl], attachment }) => { - const lightboxAttachmentIndex = lightboxAttachments.findIndex( - (a) => a.attachment.id === attachment.id + const initialLightboxEntryIndex = lightboxEntries.findIndex( + (entry) => entry.id === attachment.id ); return viewer === "lightbox" ? { @@ -198,12 +209,12 @@ export const SearchResultAttachmentsList = ({ title: attachment.description, mimeType: attachment.mimeType ?? "", onNext: buildLightboxNavigationHandler( - lightboxAttachments, - lightboxAttachmentIndex + 1 + lightboxEntries, + initialLightboxEntryIndex + 1 ), onPrevious: buildLightboxNavigationHandler( - lightboxAttachments, - lightboxAttachmentIndex - 1 + lightboxEntries, + initialLightboxEntryIndex - 1 ), }, },