This commit is contained in:
psychedelicious
2025-08-21 23:00:12 +10:00
committed by Mary Hipp Rogers
parent b938ae0a7e
commit bd38be31d8
10 changed files with 343 additions and 580 deletions

55
getItemsPerRow.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Calculate how many images fit in a row based on the current grid layout.
*
* TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value
* changes. Cache this calculation.
*/
export const getItemsPerRow = (rootEl: HTMLDivElement): number => {
// Start from root and find virtuoso grid elements
const gridElement = rootEl.querySelector('.virtuoso-grid-list');
if (!gridElement) {
return 0;
}
const firstGridItem = gridElement.querySelector('.virtuoso-grid-item');
if (!firstGridItem) {
return 0;
}
const itemRect = firstGridItem.getBoundingClientRect();
const containerRect = gridElement.getBoundingClientRect();
// Get the computed gap from CSS
const gridStyle = window.getComputedStyle(gridElement);
const gapValue = gridStyle.gap;
const gap = parseFloat(gapValue);
if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) {
return 0;
}
/**
* You might be tempted to just do some simple math like:
* const itemsPerRow = Math.floor(containerRect.width / itemRect.width);
*
* But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases.
*
* Instead, we use a more robust approach that iteratively calculates how many items fit in the row.
*/
let itemsPerRow = 0;
let spaceUsed = 0;
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
// this, without the possibility of accidentally adding an extra column.
while (spaceUsed + itemRect.width <= containerRect.width + 1) {
itemsPerRow++; // Increment the number of items
spaceUsed += itemRect.width; // Add image size to the used space
if (spaceUsed + gap <= containerRect.width) {
spaceUsed += gap; // Add gap size to the used space after each item except after the last item
}
}
return Math.max(1, itemsPerRow);
};

View File

@@ -17,7 +17,7 @@ import { useBoardName } from 'services/api/hooks/useBoardName';
import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover';
import { GalleryUploadButton } from './GalleryUploadButton';
import { GallerySearch } from './ImageGrid/GallerySearch';
import { NewGallery } from './NewGallery';
import { ImageGallery } from './NewGallery';
import { VideoGallery } from './VideoGallery';
const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '100%' };
@@ -122,7 +122,7 @@ export const GalleryPanel = memo(() => {
</Collapse>
<Divider pt={2} />
<Flex w="full" h="full" pt={2}>
{galleryView === 'images' ? <NewGallery /> : galleryView === 'videos' ? <VideoGallery /> : <NewGallery />}
{galleryView === 'videos' ? <VideoGallery /> : <ImageGallery />}
</Flex>
</Flex>
);

View File

@@ -27,57 +27,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiImageBold } from 'react-icons/pi';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
const galleryImageContainerSX = {
containerType: 'inline-size',
w: 'full',
h: 'full',
'.gallery-image-size-badge': {
'@container (max-width: 80px)': {
'&': { display: 'none' },
},
},
'&[data-is-dragging=true]': {
opacity: 0.3,
},
userSelect: 'none',
webkitUserSelect: 'none',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
'::before': {
content: '""',
display: 'inline-block',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
borderRadius: 'base',
},
'&[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
'&:hover::before': {
boxShadow:
'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
} satisfies SystemStyleObject;
import { galleryItemContainerSX } from './galleryItemContainerSX';
interface Props {
imageDTO: ImageDTO;
@@ -164,6 +114,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
getInitialData: () => {
const selection = selectSelection(store.getState());
const boardId = selectSelectedBoardId(store.getState());
// When we have multiple images selected, and the dragged image is part of the selection, initiate a
// multi-image drag.
if (selection.length > 1 && selection.includes(imageDTO.image_name)) {
@@ -244,7 +195,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
<>
<Flex
ref={ref}
sx={galleryImageContainerSX}
sx={galleryItemContainerSX}
data-is-dragging={isDragging}
data-item-id={imageDTO.image_name}
role="button"

View File

@@ -1,15 +1,15 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
import type { FlexProps } from '@invoke-ai/ui-library';
import { Flex, Icon, Image } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import type { AppDispatch, AppGetState } from 'app/store/store';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { uniq } from 'es-toolkit';
import { multipleVideoDndSource, singleVideoDndSource } from 'features/dnd/dnd';
import type { DndDragPreviewMultipleVideoState} from 'features/dnd/DndDragPreviewMultipleVideo';
import { multipleImageDndSource, multipleVideoDndSource, singleVideoDndSource } from 'features/dnd/dnd';
import type { DndDragPreviewMultipleVideoState } from 'features/dnd/DndDragPreviewMultipleVideo';
import { createMultipleVideoDragPreview, setMultipleVideoDragPreview } from 'features/dnd/DndDragPreviewMultipleVideo';
import type { DndDragPreviewSingleVideoState} from 'features/dnd/DndDragPreviewSingleVideo';
import type { DndDragPreviewSingleVideoState } from 'features/dnd/DndDragPreviewSingleVideo';
import { createSingleVideoDragPreview, setSingleVideoDragPreview } from 'features/dnd/DndDragPreviewSingleVideo';
import { firefoxDndFix } from 'features/dnd/util';
import {
@@ -22,62 +22,12 @@ import { navigationApi } from 'features/ui/layouts/navigation-api';
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
import type { MouseEvent, MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiImageBold } from 'react-icons/pi';
import { PiImageBold, PiVideoBold } from 'react-icons/pi';
import { imagesApi } from 'services/api/endpoints/images';
import type { VideoDTO } from 'services/api/types';
import { GalleryItemHoverIcons } from './GalleryItemHoverIcons';
import { useVideoContextMenu } from '../ContextMenu/VideoContextMenu';
const galleryImageContainerSX = {
containerType: 'inline-size',
w: 'full',
h: 'full',
'.gallery-image-size-badge': {
'@container (max-width: 80px)': {
'&': { display: 'none' },
},
},
'&[data-is-dragging=true]': {
opacity: 0.3,
},
userSelect: 'none',
webkitUserSelect: 'none',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
'::before': {
content: '""',
display: 'inline-block',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
borderRadius: 'base',
},
'&[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
'&:hover::before': {
boxShadow:
'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
} satisfies SystemStyleObject;
import { galleryItemContainerSX } from './galleryItemContainerSX';
interface Props {
videoDTO: VideoDTO;
@@ -131,7 +81,7 @@ const buildOnClick =
}
};
export const GalleryVideo = memo(({ videoDTO }: Props) => {
export const GalleryVideo = memo(({ videoDTO }: Props) => {
const store = useAppStore();
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewState, setDragPreviewState] = useState<
@@ -157,8 +107,16 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
const selection = selectSelection(store.getState());
const boardId = selectSelectedBoardId(store.getState());
// Otherwise, initiate a single-image drag
return singleVideoDndSource.getData({ videoDTO }, videoDTO.video_id);
// When we have multiple images selected, and the dragged image is part of the selection, initiate a
// multi-image drag.
if (selection.length > 1 && selection.includes(videoDTO.video_id)) {
return multipleVideoDndSource.getData({
ids: selection,
board_id: boardId,
});
} // Otherwise, initiate a single-image drag
return singleVideoDndSource.getData({ videoDTO }, videoDTO.video_id);
},
// This is a "local" drag start event, meaning that it is only called when this specific image is dragged.
onDragStart: ({ source }) => {
@@ -190,10 +148,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
onDragStart: ({ source }) => {
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
// selection. This is called for all drag events.
if (
multipleVideoDndSource.typeGuard(source.data) &&
source.data.payload.ids.includes(videoDTO.video_id)
) {
if (multipleVideoDndSource.typeGuard(source.data) && source.data.payload.ids.includes(videoDTO.video_id)) {
setIsDragging(true);
}
},
@@ -228,7 +183,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
<>
<Flex
ref={ref}
sx={galleryImageContainerSX}
sx={galleryItemContainerSX}
data-is-dragging={isDragging}
data-item-id={videoDTO.video_id}
role="button"
@@ -243,7 +198,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
pointerEvents="none"
src={videoDTO.thumbnail_url}
w={videoDTO.width}
fallback={<GalleryImagePlaceholder />}
fallback={<GalleryVideoPlaceholder />}
objectFit="contain"
maxW="full"
maxH="full"
@@ -259,10 +214,10 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
GalleryVideo.displayName = 'GalleryVideo';
export const GalleryImagePlaceholder = memo((props: FlexProps) => (
export const GalleryVideoPlaceholder = memo((props: FlexProps) => (
<Flex w="full" h="full" bg="base.850" borderRadius="base" alignItems="center" justifyContent="center" {...props}>
<Icon as={PiImageBold} boxSize={16} color="base.800" />
<Icon as={PiVideoBold} boxSize={16} color="base.800" />
</Flex>
));
GalleryImagePlaceholder.displayName = 'GalleryImagePlaceholder';
GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder';

View File

@@ -0,0 +1,52 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
export const galleryItemContainerSX = {
containerType: 'inline-size',
w: 'full',
h: 'full',
'.gallery-image-size-badge': {
'@container (max-width: 80px)': {
'&': { display: 'none' },
},
},
'&[data-is-dragging=true]': {
opacity: 0.3,
},
userSelect: 'none',
webkitUserSelect: 'none',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
'::before': {
content: '""',
display: 'inline-block',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
borderRadius: 'base',
},
'&[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
'&:hover::before': {
boxShadow:
'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
} satisfies SystemStyleObject;

View File

@@ -15,9 +15,8 @@ import {
} from 'features/gallery/store/gallerySelectors';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { MutableRefObject, RefObject } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import type {
GridComponents,
GridComputeItemKey,
@@ -36,6 +35,10 @@ import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag';
import { useGalleryImageNames } from './use-gallery-image-names';
import { useGalleryVideoIds } from './use-gallery-video-ids';
import { GalleryVideo } from './ImageGrid/GalleryVideo';
import { getItemsPerRow } from '../../../../../../../getItemsPerRow';
import { scrollIntoView } from './scrollIntoView';
import { useScrollableGallery } from './useScrollableGallery';
import { getItemIndex } from './getItemIndex';
const log = logger('gallery');
@@ -100,182 +103,6 @@ const computeItemKey: GridComputeItemKey<string, GridContext> = (index, imageNam
return `${JSON.stringify(queryArgs)}-${imageName ?? index}`;
};
/**
* Calculate how many images fit in a row based on the current grid layout.
*
* TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value
* changes. Cache this calculation.
*/
const getImagesPerRow = (rootEl: HTMLDivElement): number => {
// Start from root and find virtuoso grid elements
const gridElement = rootEl.querySelector('.virtuoso-grid-list');
if (!gridElement) {
return 0;
}
const firstGridItem = gridElement.querySelector('.virtuoso-grid-item');
if (!firstGridItem) {
return 0;
}
const itemRect = firstGridItem.getBoundingClientRect();
const containerRect = gridElement.getBoundingClientRect();
// Get the computed gap from CSS
const gridStyle = window.getComputedStyle(gridElement);
const gapValue = gridStyle.gap;
const gap = parseFloat(gapValue);
if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) {
return 0;
}
/**
* You might be tempted to just do some simple math like:
* const imagesPerRow = Math.floor(containerRect.width / itemRect.width);
*
* But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases.
*
* Instead, we use a more robust approach that iteratively calculates how many images fit in the row.
*/
let imagesPerRow = 0;
let spaceUsed = 0;
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
// this, without the possibility of accidentally adding an extra column.
while (spaceUsed + itemRect.width <= containerRect.width + 1) {
imagesPerRow++; // Increment the number of images
spaceUsed += itemRect.width; // Add image size to the used space
if (spaceUsed + gap <= containerRect.width) {
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
}
}
return Math.max(1, imagesPerRow);
};
/**
* Scroll the item at the given index into view if it is not currently visible.
*/
const scrollIntoView = (
targetItemId: string,
itemIds: string[],
rootEl: HTMLDivElement,
virtuosoGridHandle: VirtuosoGridHandle,
range: ListRange
) => {
if (range.endIndex === 0) {
// No range is rendered; no need to scroll to anything.
log.trace('Not scrolling into view: Range endIdex is 0');
return;
}
const targetIndex = itemIds.findIndex((name) => name === targetItemId);
if (targetIndex === -1) {
// The image isn't in the currently rendered list.
log.trace('Not scrolling into view: targetIndex is -1');
return;
}
const targetItem = rootEl.querySelector(
`.virtuoso-grid-item:has([data-item-id="${targetItemId}"])`
) as HTMLElement;
if (!targetItem) {
if (targetIndex > range.endIndex) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'start',
},
'Scrolling into view: not in DOM'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (targetIndex < range.startIndex) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'end',
},
'Scrolling into view: not in DOM'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
log.debug(
`Unable to find image ${targetItemId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}`
);
}
return;
}
// We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport.
// Check if it is in the viewport and scroll if necessary.
const itemRect = targetItem.getBoundingClientRect();
const rootRect = rootEl.getBoundingClientRect();
if (itemRect.top < rootRect.top) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'start',
},
'Scrolling into view: in overscan'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (itemRect.bottom > rootRect.bottom) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'end',
},
'Scrolling into view: in overscan'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
// Image is already in view
log.debug('Not scrolling into view: Image is already in view');
}
return;
};
/**
* Get the index of the image in the list of image names.
* If the image name is not found, return 0.
* If no image name is provided, return 0.
*/
const getImageIndex = (imageName: string | undefined | null, imageNames: string[]) => {
if (!imageName || imageNames.length === 0) {
return 0;
}
const index = imageNames.findIndex((n) => n === imageName);
return index >= 0 ? index : 0;
};
/**
* Handles keyboard navigation for the gallery.
*/
@@ -312,7 +139,7 @@ const useKeyboardNavigation = (
return;
}
const imagesPerRow = getImagesPerRow(rootEl);
const imagesPerRow = getItemsPerRow(rootEl);
if (imagesPerRow === 0) {
// This can happen if the grid is not yet rendered or has no items
@@ -328,7 +155,7 @@ const useKeyboardNavigation = (
(selectImageToCompare(state) ?? selectLastSelectedImage(state))
: selectLastSelectedImage(state);
const currentIndex = getImageIndex(imageName, imageNames);
const currentIndex = getItemIndex(imageName, imageNames);
let newIndex = currentIndex;
@@ -475,51 +302,6 @@ const useKeepSelectedImageInView = (
}, [imageNames, rangeRef, rootRef, virtuosoRef, selection]);
};
/**
* Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element.
*/
const useScrollableGallery = (rootRef: RefObject<HTMLDivElement>) => {
const [scroller, scrollerRef] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'scroll',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => {
osInstance()?.destroy();
};
}, [scroller, initialize, osInstance, rootRef]);
return scrollerRef;
};
const useStarImageHotkey = () => {
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const selectionCount = useAppSelector(selectSelectionCount);
@@ -551,7 +333,7 @@ const useStarImageHotkey = () => {
});
};
export const NewGallery = memo(() => {
export const ImageGallery = memo(() => {
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
const rootRef = useRef<HTMLDivElement>(null);
@@ -559,7 +341,6 @@ export const NewGallery = memo(() => {
// Get the ordered list of image names - this is our primary data source for virtualization
const { queryArgs, imageNames, isLoading } = useGalleryImageNames();
const { queryArgs: videoQueryArgs, videoIds, isLoading: isLoadingVideos } = useGalleryVideoIds();
// Use range-based fetching for bulk loading image DTOs into cache based on the visible range
const { onRangeChanged } = useRangeBasedImageFetching({
@@ -584,7 +365,7 @@ export const NewGallery = memo(() => {
[onRangeChanged]
);
const context = useMemo<GridContext>(() => ({ imageNames, queryArgs, videoIds, videoQueryArgs }), [imageNames, queryArgs, videoIds, videoQueryArgs]);
const context = useMemo<GridContext>(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]);
if (isLoading) {
return (
@@ -609,7 +390,7 @@ export const NewGallery = memo(() => {
<VirtuosoGrid<string, GridContext>
ref={virtuosoRef}
context={context}
data={galleryView === 'images' ? imageNames : videoIds}
data={imageNames}
increaseViewportBy={4096}
itemContent={itemContent}
computeItemKey={computeItemKey}
@@ -624,7 +405,7 @@ export const NewGallery = memo(() => {
);
});
NewGallery.displayName = 'NewGallery';
ImageGallery.displayName = 'NewGallery';
const scrollSeekConfiguration: ScrollSeekConfiguration = {
enter: (velocity) => {

View File

@@ -30,10 +30,14 @@ import { useDebounce } from 'use-debounce';
import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage';
import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag';
import { GalleryVideo } from './ImageGrid/GalleryVideo';
import { GalleryVideo, GalleryVideoPlaceholder } from './ImageGrid/GalleryVideo';
import { useGalleryVideoIds } from './use-gallery-video-ids';
import { getItemsPerRow } from '../../../../../../../getItemsPerRow';
import { scrollIntoView } from './scrollIntoView';
import { getItemIndex } from './getItemIndex';
import { useScrollableGallery } from './useScrollableGallery';
const log = logger('gallery');
export const log = logger('gallery');
type ListVideoIdsQueryArgs = ReturnType<typeof selectGetVideoIdsQueryArgs>;
@@ -67,156 +71,15 @@ const VideoAtPosition = memo(({ videoId }: { index: number; videoId: string }) =
});
VideoAtPosition.displayName = 'VideoAtPosition';
const computeItemKey: GridComputeItemKey<string, GridContext> = (index, imageName, { queryArgs }) => {
return `${JSON.stringify(queryArgs)}-${imageName ?? index}`;
};
/**
* Calculate how many images fit in a row based on the current grid layout.
*
* TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value
* changes. Cache this calculation.
*/
const getVideosPerRow = (rootEl: HTMLDivElement): number => {
// Start from root and find virtuoso grid elements
const gridElement = rootEl.querySelector('.virtuoso-grid-list');
if (!gridElement) {
return 0;
}
const firstGridItem = gridElement.querySelector('.virtuoso-grid-item');
if (!firstGridItem) {
return 0;
}
const itemRect = firstGridItem.getBoundingClientRect();
const containerRect = gridElement.getBoundingClientRect();
// Get the computed gap from CSS
const gridStyle = window.getComputedStyle(gridElement);
const gapValue = gridStyle.gap;
const gap = parseFloat(gapValue);
if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) {
return 0;
}
/**
* You might be tempted to just do some simple math like:
* const imagesPerRow = Math.floor(containerRect.width / itemRect.width);
*
* But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases.
*
* Instead, we use a more robust approach that iteratively calculates how many images fit in the row.
*/
let videosPerRow = 0;
let spaceUsed = 0;
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
// this, without the possibility of accidentally adding an extra column.
while (spaceUsed + itemRect.width <= containerRect.width + 1) {
videosPerRow++; // Increment the number of images
spaceUsed += itemRect.width; // Add image size to the used space
if (spaceUsed + gap <= containerRect.width) {
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
}
}
return Math.max(1, videosPerRow);
};
/**
* Scroll the item at the given index into view if it is not currently visible.
*/
const scrollIntoView = (
targetVideoId: string,
videoIds: string[],
rootEl: HTMLDivElement,
virtuosoGridHandle: VirtuosoGridHandle,
range: ListRange
) => {
if (range.endIndex === 0) {
// No range is rendered; no need to scroll to anything.
return;
}
const targetIndex = videoIds.findIndex((id) => id === targetVideoId);
if (targetIndex === -1) {
// The image isn't in the currently rendered list.
return;
}
const targetItem = rootEl.querySelector(
`.virtuoso-grid-item:has([data-video-id="${targetVideoId}"])`
) as HTMLElement;
if (!targetItem) {
if (targetIndex > range.endIndex) {
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (targetIndex < range.startIndex) {
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
log.debug(
`Unable to find video ${targetVideoId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}`
);
}
return;
}
// We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport.
// Check if it is in the viewport and scroll if necessary.
const itemRect = targetItem.getBoundingClientRect();
const rootRect = rootEl.getBoundingClientRect();
if (itemRect.top < rootRect.top) {
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (itemRect.bottom > rootRect.bottom) {
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
// Image is already in view
}
return;
};
/**
* Get the index of the image in the list of image names.
* If the image name is not found, return 0.
* If no image name is provided, return 0.
*/
const getVideoIndex = (videoId: string | undefined | null, videoIds: string[]) => {
if (!videoId || videoIds.length === 0) {
return 0;
}
const index = videoIds.findIndex((n) => n === videoId);
return index >= 0 ? index : 0;
const computeItemKey: GridComputeItemKey<string, GridContext> = (index, itemId, { queryArgs }) => {
return `${JSON.stringify(queryArgs)}-${itemId ?? index}`;
};
/**
* Handles keyboard navigation for the gallery.
*/
const useKeyboardNavigation = (
videoIds: string[],
itemIds: string[],
virtuosoRef: React.RefObject<VirtuosoGridHandle>,
rootRef: React.RefObject<HTMLDivElement>
) => {
@@ -244,13 +107,13 @@ const useKeyboardNavigation = (
return;
}
if (videoIds.length === 0) {
if (itemIds.length === 0) {
return;
}
const videosPerRow = getVideosPerRow(rootEl);
const itemsPerRow = getItemsPerRow(rootEl);
if (videosPerRow === 0) {
if (itemsPerRow === 0) {
// This can happen if the grid is not yet rendered or has no items
return;
}
@@ -258,13 +121,9 @@ const useKeyboardNavigation = (
event.preventDefault();
const state = getState();
const videoId = event.altKey
? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected,
// we start from the last selected image
(selectImageToCompare(state) ?? selectLastSelectedImage(state))
: selectLastSelectedImage(state);
const itemId = selectLastSelectedImage(state);
const currentIndex = getVideoIndex(videoId, videoIds);
const currentIndex = getItemIndex(itemId, itemIds);
let newIndex = currentIndex;
@@ -272,47 +131,39 @@ const useKeyboardNavigation = (
case 'ArrowLeft':
if (currentIndex > 0) {
newIndex = currentIndex - 1;
// } else {
// // Wrap to last image
// newIndex = imageNames.length - 1;
}
break;
case 'ArrowRight':
if (currentIndex < videoIds.length - 1) {
if (currentIndex < itemIds.length - 1) {
newIndex = currentIndex + 1;
// } else {
// // Wrap to first image
// newIndex = 0;
}
break;
case 'ArrowUp':
// If on first row, stay on current image
if (currentIndex < videosPerRow) {
// If on first row, stay on current item
if (currentIndex < itemsPerRow) {
newIndex = currentIndex;
} else {
newIndex = Math.max(0, currentIndex - videosPerRow);
newIndex = Math.max(0, currentIndex - itemsPerRow);
}
break;
case 'ArrowDown':
// If no images below, stay on current image
if (currentIndex >= videoIds.length - videosPerRow) {
// If no items below, stay on current item
if (currentIndex >= itemIds.length - itemsPerRow) {
newIndex = currentIndex;
} else {
newIndex = Math.min(videoIds.length - 1, currentIndex + videosPerRow);
newIndex = Math.min(itemIds.length - 1, currentIndex + itemsPerRow);
}
break;
}
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < videoIds.length) {
const newVideoId = videoIds[newIndex];
if (newVideoId) {
dispatch(selectionChanged([newVideoId]));
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < itemIds.length) {
const nextItemId = itemIds[newIndex];
if (nextItemId) {
dispatch(selectionChanged([nextItemId]));
}
}
},
[rootRef, virtuosoRef, videoIds, getState, dispatch]
[rootRef, virtuosoRef, itemIds, getState, dispatch]
);
useRegisteredHotkeys({
@@ -405,58 +256,10 @@ const useKeepSelectedVideoInView = (
}, [targetVideoId, videoIds, rangeRef, rootRef, virtuosoRef]);
};
/**
* Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element.
*/
const useScrollableGallery = (rootRef: RefObject<HTMLDivElement>) => {
const [scroller, scrollerRef] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'scroll',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => {
osInstance()?.destroy();
};
}, [scroller, initialize, osInstance, rootRef]);
return scrollerRef;
};
export const VideoGallery = memo(() => {
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const galleryView = useAppSelector(selectGalleryView);
// Get the ordered list of image names - this is our primary data source for virtualization
const { queryArgs, videoIds, isLoading } = useGalleryVideoIds();
@@ -492,7 +295,7 @@ export const VideoGallery = memo(() => {
<Text color="base.300">Loading gallery...</Text>
</Flex>
);
}
}
if (videoIds.length === 0) {
return (
@@ -513,7 +316,7 @@ export const VideoGallery = memo(() => {
itemContent={itemContent}
computeItemKey={computeItemKey}
components={components}
style={style}
style={virtuosoGridStyle}
scrollerRef={scrollerRef}
scrollSeekConfiguration={scrollSeekConfiguration}
rangeChanged={handleRangeChanged}
@@ -535,7 +338,7 @@ const scrollSeekConfiguration: ScrollSeekConfiguration = {
};
// Styles
const style = { height: '100%', width: '100%' };
const virtuosoGridStyle = { height: '100%', width: '100%' };
const selectGridTemplateColumns = createSelector(
selectGalleryImageMinimumWidth,
@@ -562,7 +365,7 @@ ItemComponent.displayName = 'ItemComponent';
const ScrollSeekPlaceholderComponent: GridComponents<GridContext>['ScrollSeekPlaceholder'] = (props) => (
<GridItem aspectRatio="1/1" {...props}>
<GalleryImagePlaceholder />
<GalleryVideoPlaceholder />
</GridItem>
);

View File

@@ -0,0 +1,12 @@
/**
* Get the index of the item in the list of item names.
* If the item name is not found, return 0.
* If no item name is provided, return 0.
*/
export const getItemIndex = (targetItemId: string | undefined | null, itemIds: string[]) => {
if (!targetItemId || itemIds.length === 0) {
return 0;
}
const index = itemIds.findIndex((n) => n === targetItemId);
return index >= 0 ? index : 0;
};

View File

@@ -0,0 +1,107 @@
import type { VirtuosoGridHandle, ListRange } from 'react-virtuoso';
import { log } from './VideoGallery';
/**
* Scroll the item at the given index into view if it is not currently visible.
*/
export const scrollIntoView = (
targetItemId: string,
itemIds: string[],
rootEl: HTMLDivElement,
virtuosoGridHandle: VirtuosoGridHandle,
range: ListRange
) => {
if (range.endIndex === 0) {
// No range is rendered; no need to scroll to anything.
log.trace('Not scrolling into view: Range endIdex is 0');
return;
}
const targetIndex = itemIds.findIndex((name) => name === targetItemId);
if (targetIndex === -1) {
// The image isn't in the currently rendered list.
log.trace('Not scrolling into view: targetIndex is -1');
return;
}
const targetItem = rootEl.querySelector(`.virtuoso-grid-item:has([data-item-id="${targetItemId}"])`) as HTMLElement;
if (!targetItem) {
if (targetIndex > range.endIndex) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'start',
},
'Scrolling into view: not in DOM'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (targetIndex < range.startIndex) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'end',
},
'Scrolling into view: not in DOM'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
log.debug(
`Unable to find image ${targetItemId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}`
);
}
return;
}
// We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport.
// Check if it is in the viewport and scroll if necessary.
const itemRect = targetItem.getBoundingClientRect();
const rootRect = rootEl.getBoundingClientRect();
if (itemRect.top < rootRect.top) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'start',
},
'Scrolling into view: in overscan'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (itemRect.bottom > rootRect.bottom) {
log.trace(
{
index: targetIndex,
behavior: 'auto',
align: 'end',
},
'Scrolling into view: in overscan'
);
virtuosoGridHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
// Image is already in view
log.debug('Not scrolling into view: Image is already in view');
}
return;
};

View File

@@ -0,0 +1,47 @@
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { type RefObject, useState, useEffect } from 'react';
/**
* Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element.
*/
export const useScrollableGallery = (rootRef: RefObject<HTMLDivElement>) => {
const [scroller, scrollerRef] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'scroll',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => {
osInstance()?.destroy();
};
}, [scroller, initialize, osInstance, rootRef]);
return scrollerRef;
};