diff --git a/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list-item-details.component.tsx b/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list-item-details.component.tsx index 02d93629..7be36764 100644 --- a/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list-item-details.component.tsx +++ b/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list-item-details.component.tsx @@ -78,7 +78,9 @@ export const DatasetListItemDetails = ({ - {name} + + {name} + {name} diff --git a/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list.component.tsx b/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list.component.tsx index c6747263..58fcf164 100644 --- a/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list.component.tsx +++ b/web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list.component.tsx @@ -1,16 +1,14 @@ // Copyright (C) 2022-2025 Intel Corporation // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE -import { useEffect, useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { Flex } from '@adobe/react-spectrum'; import { Loading } from '@geti/ui'; import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query'; import { isEmpty } from 'lodash-es'; -import { VirtuosoGridHandle } from 'react-virtuoso'; import { MediaAdvancedFilterResponse, MediaItem, MediaItemResponse } from '../../../../../core/media/media.interface'; -import { usePrevious } from '../../../../../hooks/use-previous/use-previous.hook'; import { MediaItemsList } from '../../../../../shared/components/media-items-list/media-items-list.component'; import { ViewModes } from '../../../../../shared/components/media-view-modes/utils'; import { NotFound } from '../../../../../shared/components/not-found/not-found.component'; @@ -54,15 +52,11 @@ export const DatasetList = ({ selectedMediaItem, isInActiveMode = false, isMediaFilterEmpty = false, - previouslySelectedMediaItem, shouldShowAnnotationIndicator, }: DatasetListProps): JSX.Element => { - const ref = useRef(null); - const { hasNextPage, isPending: isMediaItemsLoading, isFetchingNextPage, fetchNextPage, data } = mediaItemsQuery; const mediaItems = useMemo(() => data?.pages?.flatMap(({ media }) => media) ?? [], [data?.pages]); - const prevViewMode = usePrevious(viewMode); const loadNextMedia = async () => { if (isInActiveMode) { @@ -77,32 +71,6 @@ export const DatasetList = ({ const groupedMediaItems = useGroupedMediaItems(mediaItems); const mediaItemIndex = useSelectedMediaItemIndex(mediaItems, selectedMediaItem, isInActiveMode); - useEffect(() => { - let timeoutId: ReturnType | null = null; - const mediaItemChanged = previouslySelectedMediaItem?.identifier !== selectedMediaItem?.identifier; - const viewModeChanged = prevViewMode !== viewMode; - - // We want to automatically scroll to the previously selected media. - // But only if the media item or view mode is different. - if ((mediaItemChanged || viewModeChanged) && ref.current) { - timeoutId = setTimeout(() => { - ref.current?.scrollToIndex({ - index: mediaItemIndex, - behavior: 'smooth', - align: 'center', - }); - // we don't want to scroll immediately - // in case of changed view mode we have to scroll once view is rendered - }, 500); - } - - return () => { - timeoutId && clearTimeout(timeoutId); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewMode, selectedMediaItem?.identifier, previouslySelectedMediaItem?.identifier, prevViewMode]); - const allPagesAreEmpty = data?.pages.every((page) => isEmpty(page.media)); const shouldShowNotFound = allPagesAreEmpty && !isMediaItemsLoading && !isMediaFilterEmpty && !isInActiveMode; @@ -136,6 +104,7 @@ export const DatasetList = ({ getTextValue={(item) => item.name} mediaItems={groupedMediaItems} viewModeSettings={viewModeSettings} + scrollToIndex={mediaItemIndex} itemContent={(mediaItem) => { return ( item.name} mediaItems={media} viewMode={viewMode} + viewModeSettings={isAnomalyProject ? VIEW_MODE_SETTINGS_ANOMALY : VIEW_MODE_SETTINGS} itemContent={(item) => ( { - const ref = useRef(null); const { isLoading: isMediaItemsLoading, isFetchingNextPage, data } = mediaItemsQuery; const mediaItems = useMemo(() => data?.pages?.flatMap(({ media }) => media) ?? [], [data?.pages]); @@ -71,28 +69,6 @@ export const TestMediaItemsList = ({ const mediaItemIndex = useSelectedMediaItemIndex(mediaItems, selectedMediaItem, false, true); - useEffect(() => { - let timeoutId: ReturnType | null = null; - - if (ref.current && selectedMediaItem !== undefined) { - timeoutId = setTimeout(() => { - ref.current?.scrollToIndex({ - index: mediaItemIndex, - behavior: 'smooth', - align: 'center', - }); - // we don't want to scroll immediately - // in case of changed view mode we have to scroll once view is rendered - }, 500); - } - - return () => { - timeoutId && clearTimeout(timeoutId); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedMediaItem]); - if (isMediaItemsLoading) { return ( @@ -124,6 +100,7 @@ export const TestMediaItemsList = ({ viewModeSettings={viewModeSettings} idFormatter={getTestMediaItemId} getTextValue={(item) => item.media.name} + scrollToIndex={mediaItemIndex} itemContent={(mediaItem) => { const mediaImageItem = mediaItem as unknown as TestImageMediaItem; const handleSelectMediaItem = () => selectMediaItem(mediaItem); diff --git a/web_ui/src/shared/components/media-items-list/media-items-list.component.tsx b/web_ui/src/shared/components/media-items-list/media-items-list.component.tsx index f619fd8f..1e371799 100644 --- a/web_ui/src/shared/components/media-items-list/media-items-list.component.tsx +++ b/web_ui/src/shared/components/media-items-list/media-items-list.component.tsx @@ -16,6 +16,7 @@ import { } from 'react-aria-components'; import { VIEW_MODE_SETTINGS, ViewModes } from '../media-view-modes/utils'; +import { useScrollToTargetItem } from './use-scroll-to-target-item.hook'; import classes from './media-items-list.module.scss'; @@ -26,6 +27,7 @@ interface MediaItemsListProps { viewMode: ViewModes; mediaItems: T[]; height?: Responsive; + scrollToIndex?: number; viewModeSettings?: ViewModeSettings; endReached?: () => void; itemContent: (item: T) => ReactNode; @@ -38,6 +40,7 @@ export const MediaItemsList = ({ height, viewMode, mediaItems, + scrollToIndex, ariaLabel = 'media items list', viewModeSettings = VIEW_MODE_SETTINGS, itemContent, @@ -49,9 +52,6 @@ export const MediaItemsList = ({ const isDetails = viewMode === ViewModes.DETAILS; const layout = isDetails ? 'stack' : 'grid'; - const ref = useRef(null); - useLoadMore({ onLoadMore: endReached }, ref); - const layoutOptions = isDetails ? { gap: config.gap, @@ -64,6 +64,19 @@ export const MediaItemsList = ({ preserveAspectRatio: true, }; + const ref = useRef(null); + useLoadMore({ onLoadMore: endReached }, ref); + + const container = ref?.current?.firstElementChild; + + useScrollToTargetItem({ + gap: config.gap, + container, + targetIndex: scrollToIndex, + dependencies: [scrollToIndex, viewMode, container], + callback: (top) => ref.current?.scrollTo({ top, behavior: 'smooth' }), + }); + return ( diff --git a/web_ui/src/shared/components/media-items-list/use-scroll-to-target-item.hook.test.tsx b/web_ui/src/shared/components/media-items-list/use-scroll-to-target-item.hook.test.tsx new file mode 100644 index 00000000..5fe9cfe9 --- /dev/null +++ b/web_ui/src/shared/components/media-items-list/use-scroll-to-target-item.hook.test.tsx @@ -0,0 +1,101 @@ +// Copyright (C) 2022-2025 Intel Corporation +// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE + +import { renderHook } from '@testing-library/react'; + +import { useScrollToTargetItem } from './use-scroll-to-target-item.hook'; + +jest.useFakeTimers(); + +describe('useScrollToTargetItem', () => { + const mockCallback = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + it('should not call callback when container is not provided', () => { + renderHook(() => + useScrollToTargetItem({ + gap: 10, + container: null, + targetIndex: 5, + callback: mockCallback, + }) + ); + + jest.advanceTimersByTime(500); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('calls callback with correct scroll position', () => { + const gap = 0; + const childWidth = 100; + const childHeight = 100; + const targetIndex = 12; + const containerWidth = 200; + + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: containerWidth }); + + const child = document.createElement('div'); + Object.defineProperty(child, 'clientWidth', { value: childWidth }); + Object.defineProperty(child, 'clientHeight', { value: childHeight }); + + container.appendChild(child); + + const itemsPerRow = Math.floor(containerWidth / childWidth); // 2 + const targetRow = Math.floor(targetIndex / itemsPerRow); // 6 + const expectedScrollPos = (childHeight + gap) * targetRow; // 600 + + renderHook(() => + useScrollToTargetItem({ + gap, + container, + targetIndex, + callback: mockCallback, + }) + ); + + jest.advanceTimersByTime(500); + + expect(mockCallback).toHaveBeenCalledWith(expectedScrollPos); + }); + + it('return zero when container has no children', () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 1000 }); + + renderHook(() => + useScrollToTargetItem({ + gap: 10, + container, + targetIndex: 5, + callback: mockCallback, + }) + ); + + jest.advanceTimersByTime(500); + + expect(mockCallback).toHaveBeenCalledWith(0); + }); + + describe('should not call callback with invalid index', () => { + it.each([undefined, null, -1, 1.5, NaN])('targetIndex: %p', (invalidIndex) => { + renderHook(() => + useScrollToTargetItem({ + gap: 10, + container: document.createElement('div'), + targetIndex: invalidIndex as number, + callback: mockCallback, + }) + ); + + jest.advanceTimersByTime(500); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web_ui/src/shared/components/media-items-list/use-scroll-to-target-item.hook.tsx b/web_ui/src/shared/components/media-items-list/use-scroll-to-target-item.hook.tsx new file mode 100644 index 00000000..d8c1dd92 --- /dev/null +++ b/web_ui/src/shared/components/media-items-list/use-scroll-to-target-item.hook.tsx @@ -0,0 +1,48 @@ +// Copyright (C) 2022-2025 Intel Corporation +// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE + +import { DependencyList, useEffect } from 'react'; + +import { isNil } from 'lodash-es'; + +interface useScrollToTargetItemProps { + gap: number; + dependencies?: DependencyList; + container?: Element | null; + targetIndex?: number; + callback: (scrollTo: number) => void; +} + +const isValidIndex = (index?: number): index is number => !isNil(index) && Number.isInteger(index) && index >= 0; + +export const useScrollToTargetItem = ({ + gap, + container, + targetIndex, + dependencies = [], + callback, +}: useScrollToTargetItemProps) => { + useEffect(() => { + const timeoutId = setTimeout(() => { + if (!container || !isValidIndex(targetIndex)) { + return; + } + + const containerWidth = container.clientWidth; + const childrenWidth = container.firstElementChild?.clientWidth ?? 1; + const childrenHeight = container.firstElementChild?.clientHeight ?? 1; + const childrenPreRow = Math.floor(containerWidth / childrenWidth); + const targetRow = Math.floor(targetIndex / childrenPreRow); + const scrollTo = (childrenHeight + gap) * targetRow; + + callback(scrollTo); + // we don't want to scroll immediately + // in case of changed view mode we have to scroll once view is rendered + }, 500); + + return () => { + timeoutId && clearTimeout(timeoutId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies); +}; diff --git a/web_ui/src/shared/components/media-view-modes/utils.ts b/web_ui/src/shared/components/media-view-modes/utils.ts index e9df01a4..76e02f15 100644 --- a/web_ui/src/shared/components/media-view-modes/utils.ts +++ b/web_ui/src/shared/components/media-view-modes/utils.ts @@ -12,8 +12,8 @@ export const INITIAL_VIEW_MODE = ViewModes.MEDIUM; export const VIEW_MODE_LABEL = 'View mode'; export const VIEW_MODE_SETTINGS = { - [ViewModes.SMALL]: { minItemSize: 112, gap: 4, maxColumns: 11 }, + [ViewModes.LARGE]: { minItemSize: 300, gap: 8, maxColumns: 4 }, [ViewModes.MEDIUM]: { minItemSize: 150, gap: 8, maxColumns: 8 }, - [ViewModes.LARGE]: { minItemSize: 300, gap: 12, maxColumns: 4 }, + [ViewModes.SMALL]: { minItemSize: 112, gap: 4, maxColumns: 11 }, [ViewModes.DETAILS]: { size: 81, gap: 0 }, };