mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
gallery
This commit is contained in:
committed by
Mary Hipp Rogers
parent
b938ae0a7e
commit
bd38be31d8
55
getItemsPerRow.ts
Normal file
55
getItemsPerRow.ts
Normal 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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user