From 76ce21dbbd7f8135fc4ea1163be59fbae216a32f Mon Sep 17 00:00:00 2001 From: nikec <43032218+niikeec@users.noreply.github.com> Date: Mon, 12 Feb 2024 18:43:26 +0100 Subject: [PATCH] [ENG-951] Media view date header (#2076) * Split up grid into grid and media view, drag select, key selection, date header * fix types * header shadow * set selecto drag container * make shadow more visible and make text smaller * fix date by key and sort direction * fix truncated text jumping * bump virtual-grid and replace totalCount * cleanup a bit * remove allowMultiselect option --- .../Explorer/QuickPreview/ImageSlider.tsx | 5 +- .../app/$libraryId/Explorer/View/Context.ts | 6 +- .../View/Grid/DragSelect/DragSelectable.tsx | 17 + .../Explorer/View/Grid/DragSelect/context.tsx | 20 + .../Explorer/View/Grid/DragSelect/index.tsx | 544 +++++++++++++++ .../Grid/DragSelect/useDragSelectable.tsx | 58 ++ .../Grid/DragSelect/useSelectedTargets.tsx | 35 + .../Explorer/View/Grid/DragSelect/util.ts | 16 + .../$libraryId/Explorer/View/Grid/Item.tsx | 66 +- .../$libraryId/Explorer/View/Grid/context.tsx | 18 - .../$libraryId/Explorer/View/Grid/index.tsx | 633 ------------------ .../Explorer/View/Grid/useKeySelection.tsx | 152 +++++ .../Explorer/View/GridView/index.tsx | 71 +- .../Explorer/View/ListView/index.tsx | 614 +++++++++-------- .../Explorer/View/MediaView/DateHeader.tsx | 58 ++ .../Explorer/View/MediaView/index.tsx | 231 ++++++- .../Explorer/View/MediaView/util.ts | 16 + .../app/$libraryId/Explorer/View/index.tsx | 5 + interface/app/$libraryId/Explorer/index.tsx | 5 +- .../app/$libraryId/Explorer/useExplorer.ts | 5 - interface/app/$libraryId/Explorer/util.ts | 9 + interface/package.json | 2 +- pnpm-lock.yaml | Bin 959439 -> 960554 bytes 23 files changed, 1543 insertions(+), 1043 deletions(-) create mode 100644 interface/app/$libraryId/Explorer/View/Grid/DragSelect/DragSelectable.tsx create mode 100644 interface/app/$libraryId/Explorer/View/Grid/DragSelect/context.tsx create mode 100644 interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx create mode 100644 interface/app/$libraryId/Explorer/View/Grid/DragSelect/useDragSelectable.tsx create mode 100644 interface/app/$libraryId/Explorer/View/Grid/DragSelect/useSelectedTargets.tsx create mode 100644 interface/app/$libraryId/Explorer/View/Grid/DragSelect/util.ts delete mode 100644 interface/app/$libraryId/Explorer/View/Grid/context.tsx delete mode 100644 interface/app/$libraryId/Explorer/View/Grid/index.tsx create mode 100644 interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx create mode 100644 interface/app/$libraryId/Explorer/View/MediaView/DateHeader.tsx create mode 100644 interface/app/$libraryId/Explorer/View/MediaView/util.ts diff --git a/interface/app/$libraryId/Explorer/QuickPreview/ImageSlider.tsx b/interface/app/$libraryId/Explorer/QuickPreview/ImageSlider.tsx index c7856573a..f51faeaea 100644 --- a/interface/app/$libraryId/Explorer/QuickPreview/ImageSlider.tsx +++ b/interface/app/$libraryId/Explorer/QuickPreview/ImageSlider.tsx @@ -46,7 +46,10 @@ export const ImageSlider = ({ activeItem }: { activeItem: QuickPreviewItem }) => const { index } = activeItem; if (index === activeIndex.current) return; - const { left: rectLeft, right: rectRight, width: rectWidth } = grid.getItemRect(index); + const gridItem = grid.getItem(index); + if (!gridItem) return; + + const { left: rectLeft, right: rectRight, width: rectWidth } = gridItem.rect; const { clientWidth: containerWidth, scrollLeft: containerScrollLeft } = element; diff --git a/interface/app/$libraryId/Explorer/View/Context.ts b/interface/app/$libraryId/Explorer/View/Context.ts index b339c98d2..1c67dd3d8 100644 --- a/interface/app/$libraryId/Explorer/View/Context.ts +++ b/interface/app/$libraryId/Explorer/View/Context.ts @@ -2,8 +2,10 @@ import { createContext, useContext, type ReactNode, type RefObject } from 'react export interface ExplorerViewContext { ref: RefObject; - top?: number; - bottom?: number; + /** + * Padding to apply when scrolling to an item. + */ + scrollPadding?: { top?: number; bottom?: number }; contextMenu?: ReactNode; selectable: boolean; listViewOptions?: { diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/DragSelectable.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/DragSelectable.tsx new file mode 100644 index 000000000..3192018c1 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/DragSelectable.tsx @@ -0,0 +1,17 @@ +import { HTMLAttributes, PropsWithChildren } from 'react'; + +import { useDragSelectable, UseDragSelectableProps } from './useDragSelectable'; + +interface DragSelectableProps extends PropsWithChildren, HTMLAttributes { + selectable: UseDragSelectableProps; +} + +export const DragSelectable = ({ children, selectable, ...props }: DragSelectableProps) => { + const { attributes } = useDragSelectable(selectable); + + return ( +
+ {children} +
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/context.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/context.tsx new file mode 100644 index 000000000..95cbb3b97 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/context.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; +import Selecto from 'react-selecto'; + +import { Drag } from '.'; +import { useSelectedTargets } from './useSelectedTargets'; + +interface DragSelectContext extends ReturnType { + selecto: React.RefObject; + drag: React.MutableRefObject; +} + +export const DragSelectContext = createContext(null); + +export const useDragSelectContext = () => { + const ctx = useContext(DragSelectContext); + + if (ctx === null) throw new Error('DragSelectContext.Provider not found!'); + + return ctx; +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx new file mode 100644 index 000000000..fb180c75c --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx @@ -0,0 +1,544 @@ +import { useGrid } from '@virtual-grid/react'; +import { PropsWithChildren, useEffect, useRef } from 'react'; +import Selecto, { SelectoEvents } from 'react-selecto'; +import { ExplorerItem } from '@sd/client'; + +import { useExplorerContext } from '../../../Context'; +import { explorerStore } from '../../../store'; +import { useExplorerViewContext } from '../../Context'; +import { DragSelectContext } from './context'; +import { useSelectedTargets } from './useSelectedTargets'; +import { getElementIndex, SELECTABLE_DATA_ATTRIBUTE } from './util'; + +const CHROME_REGEX = /Chrome/; + +interface Props extends PropsWithChildren { + grid: ReturnType>; + onActiveItemChange: (item: ExplorerItem | null) => void; +} + +export interface Drag { + startColumn: number; + endColumn: number; + startRow: number; + endRow: number; +} + +export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { + const isChrome = CHROME_REGEX.test(navigator.userAgent); + + const explorer = useExplorerContext(); + const explorerView = useExplorerViewContext(); + + const selecto = useRef(null); + + const drag = useRef(null); + + const selectedTargets = useSelectedTargets(selecto); + + useEffect(() => { + if (explorer.selectedItems.size !== 0) return; + selectedTargets.resetSelectedTargets(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [explorer.selectedItems, selectedTargets.resetSelectedTargets]); + + useEffect(() => { + const node = explorer.scrollRef.current; + if (!node) return; + + const handleScroll = () => { + selecto.current?.checkScroll(); + selecto.current?.findSelectableTargets(); + }; + + node.addEventListener('scroll', handleScroll); + return () => node.removeEventListener('scroll', handleScroll); + }, [explorer.scrollRef]); + + function getGridItem(element: Element) { + const index = getElementIndex(element); + return (index !== null && grid.getItem(index)) || undefined; + } + + function handleScroll(e: SelectoEvents['scroll']) { + selecto.current?.findSelectableTargets(); + explorer.scrollRef.current?.scrollBy( + (e.direction[0] || 0) * 10, + (e.direction[1] || 0) * 10 + ); + } + + function handleDrag(e: SelectoEvents['drag']) { + if (!explorerStore.drag) return; + e.stop(); + handleDragEnd(); + } + + function handleDragStart(_: SelectoEvents['dragStart']) { + explorerStore.isDragSelecting = true; + } + + function handleDragEnd() { + explorerStore.isDragSelecting = false; + drag.current = null; + + // Set active item to the first selected target + // Targets are already sorted + const target = selecto.current?.getSelectedTargets()?.[0]; + const item = target && getGridItem(target)?.data; + if (item) onActiveItemChange(item); + } + + function handleSelect(e: SelectoEvents['select']) { + const inputEvent = e.inputEvent as MouseEvent; + + // Handle select on mouse down + if (inputEvent.type === 'mousedown') { + const element = inputEvent.shiftKey ? e.added[0] || e.removed[0] : e.selected[0]; + if (!element) return; + + const item = getGridItem(element); + if (!item?.data) return; + + drag.current = { + startColumn: item.column, + endColumn: item.column, + startRow: item.row, + endRow: item.row + }; + + if (!inputEvent.shiftKey) { + if (explorer.selectedItems.has(item.data)) { + // Keep previous selection as selecto will reset it otherwise + selecto.current?.setSelectedTargets(e.beforeSelected); + } else { + explorer.resetSelectedItems([item.data]); + selectedTargets.resetSelectedTargets([ + { id: String(item.id), node: element as HTMLElement } + ]); + } + + return; + } + + if (e.added[0]) explorer.addSelectedItem(item.data); + else explorer.removeSelectedItem(item.data); + } + + // Handle select by drag + if (inputEvent.type === 'mousemove') { + // Collect all elements from the drag event + // that are still in the DOM + const elements: Element[] = []; + + e.added.forEach((element) => { + const item = getGridItem(element); + if (!item?.data) return; + + // Add item to selected targets + // Don't update selecto as it's already aware of it + selectedTargets.addSelectedTarget(String(item.id), element as HTMLElement, { + updateSelecto: false + }); + + explorer.addSelectedItem(item.data); + if (document.contains(element)) elements.push(element); + }); + + e.removed.forEach((element) => { + const item = getGridItem(element); + if (!item?.data) return; + + // Remove item from selected targets + // Don't update selecto as it's already aware of it + selectedTargets.removeSelectedTarget(String(item.id), { updateSelecto: false }); + + // Don't deselect item if element is unmounted by scroll + if (!document.contains(element)) return; + + explorer.removeSelectedItem(item.data); + elements.push(element); + }); + + const dragDirection = { + x: inputEvent.x === e.rect.left ? 'left' : 'right', + y: inputEvent.y === e.rect.bottom ? 'down' : 'up' + } as const; + + const dragStart = { + x: dragDirection.x === 'right' ? e.rect.left : e.rect.right, + y: dragDirection.y === 'down' ? e.rect.top : e.rect.bottom + }; + + const dragEnd = { + x: inputEvent.x, + y: inputEvent.y + }; + + const dragRect = { + top: dragDirection.y === 'down' ? dragStart.y : dragEnd.y, + bottom: dragDirection.y === 'down' ? dragEnd.y : dragStart.y, + left: dragDirection.x === 'right' ? dragStart.x : dragEnd.x, + right: dragDirection.x === 'right' ? dragEnd.x : dragStart.x + }; + + // Group elements by column + const columnItems = elements.reduce( + (items, element) => { + const item = getGridItem(element); + if (!item) return items; + + const columnItem = { item, node: element as HTMLElement }; + + let firstItem = items[item.column]?.firstItem ?? columnItem; + let lastItem = items[item.column]?.lastItem ?? columnItem; + + if (dragDirection.y === 'down') { + if (item.row < firstItem.item.row) firstItem = columnItem; + if (item.row > lastItem.item.row) lastItem = columnItem; + } else { + if (item.row > firstItem.item.row) firstItem = columnItem; + if (item.row < lastItem.item.row) lastItem = columnItem; + } + + items[item.column] = { firstItem, lastItem }; + + return items; + }, + {} as Record< + number, + Record< + 'firstItem' | 'lastItem', + { item: NonNullable>; node: HTMLElement } + > + > + ); + + const columns = Object.keys(columnItems).map((column) => Number(column)); + + // Sort columns in drag direction + columns.sort((a, b) => (dragDirection.x === 'right' ? a - b : b - a)); + + // Helper function to check if the element is within the drag area + const isItemInDragArea = (item: HTMLElement, asis: 'x' | 'y' | 'all' = 'all') => { + const rect = item.getBoundingClientRect(); + + const inX = dragRect.left <= rect.right && dragRect.right >= rect.left; + const inY = dragRect.top <= rect.bottom && dragRect.bottom >= rect.top; + + return asis === 'all' ? inX && inY : asis === 'x' ? inX : inY; + }; + + const addedColumns = new Set(); + const removedColumns = new Set(); + + const addedRows = new Set(); + const removedRows = new Set(); + + columns.forEach((column) => { + const { firstItem, lastItem } = columnItems[column]!; + + const { row: firstRow } = firstItem.item; + const { row: lastRow } = lastItem.item; + + const isItemInDrag = isItemInDragArea(lastItem.node); + const isColumnInDrag = isItemInDragArea(lastItem.node, 'x'); + const isFirstRowInDrag = isItemInDragArea(firstItem.node, 'y'); + const isLastRowInDrag = isItemInDragArea(lastItem.node, 'y'); + + const isColumnInDragRange = drag.current + ? dragDirection.x === 'right' + ? column >= drag.current.startColumn && column <= drag.current.endColumn + : column <= drag.current.startColumn && column >= drag.current.endColumn + : undefined; + + const isFirstRowInDragRange = drag.current + ? dragDirection.y === 'down' + ? firstRow >= drag.current.startRow && firstRow <= drag.current.endRow + : firstRow <= drag.current.startRow && firstRow >= drag.current.endRow + : undefined; + + const isLastRowInDragRange = drag.current + ? dragDirection.y === 'down' + ? lastRow >= drag.current.startRow && lastRow <= drag.current.endRow + : lastRow <= drag.current.startRow && lastRow >= drag.current.endRow + : undefined; + + // Remove first row if we drag out of it and it's the starting row of the drag + if (!isFirstRowInDrag && firstRow === drag.current?.startRow) { + removedRows.add(firstRow); + } + + // Remove last row if we drag out of it and it's the ending row of the drag + if (!isLastRowInDrag && lastRow === drag.current?.endRow) { + removedRows.add(lastRow); + } + + // Set new start row if we dragged over a row that's not in the drag range + if (!isFirstRowInDragRange && isFirstRowInDrag) { + addedRows.add(firstRow); + } + + // Set new end row if we dragged over a row that's not in the drag range + if (!isLastRowInDragRange && isLastRowInDrag) { + addedRows.add(lastRow); + } + + // Prevent first row from being removed if it was previously tagged as removable + // Can happen when the drag event catches multiple columns at once + if (isFirstRowInDrag && removedRows.has(firstRow)) { + removedRows.delete(firstRow); + } + + // Prevent last row from being removed if it was previously tagged as removable + // Can happen when the drag event catches multiple columns at once + if (isLastRowInDrag && removedRows.has(lastRow)) { + removedRows.delete(lastRow); + } + + // Remove rows if we drag out of the starting column + if (!isColumnInDrag && column === drag.current?.startColumn) { + removedRows.add(firstRow); + removedRows.add(lastRow); + } + + if (!isColumnInDrag && dragDirection.x === 'left') { + // Get the item that's closest to grid's end + const item = dragDirection.y === 'down' ? lastItem : firstItem; + + // Remove row if dragged out of the last grid item + // from a row that's above it + if (item.item.index === grid.totalCount - 1) { + removedRows.add(item.item.row); + } + } + + // Add column if dragged over and it's not in the drag range + if (isColumnInDrag && !isColumnInDragRange) { + addedColumns.add(column); + } + + // Remove column when dragged out of the column or starting row + if (!isColumnInDrag || (firstRow === drag.current?.startRow && !isLastRowInDrag)) { + removedColumns.add(column); + } + + // Remove columns that are not in the new selected row, when the drag event + // caches multiple rows at once, and the first one being removed + if ( + !isFirstRowInDrag && + firstRow === grid.totalRowCount - 2 && + firstItem.item.index + grid.totalColumnCount > grid.totalCount - 1 + ) { + removedColumns.add(column); + } + + // Return if first row equals the first/last row of the grid (depending on drag direction) + // as there's no items to be selected beyond that point + if (!drag.current && (firstRow === 0 || firstRow === grid.totalRowCount - 1)) { + return; + } + + // Return if column is already in drag range + if (isColumnInDrag && isColumnInDragRange) { + return; + } + + const viewTop = explorerView.ref.current?.getBoundingClientRect().top ?? 0; + + const itemTop = firstItem.item.rect.top + viewTop; + const itemBottom = firstItem.item.rect.bottom + viewTop; + + const hasEmptySpace = + dragDirection.y === 'down' ? dragStart.y < itemTop : dragStart.y > itemBottom; + + if (!hasEmptySpace) return; + + // Get the heigh of the empty drag space between the start of the drag + // and the first visible item + const emptySpaceHeight = Math.abs( + dragStart.y - (dragDirection.y === 'down' ? itemTop : itemBottom) + ); + + // Check how many items we can fit into the empty space + let itemsInEmptySpace = + (emptySpaceHeight - (grid.gap.y ?? 0)) / + (grid.virtualItemHeight + (grid.gap.y ?? 0)); + + if (itemsInEmptySpace > 1) { + itemsInEmptySpace = Math.ceil(itemsInEmptySpace); + } else { + itemsInEmptySpace = Math.round(itemsInEmptySpace); + } + + [...Array(itemsInEmptySpace)].forEach((_, i) => { + i = dragDirection.y === 'down' ? itemsInEmptySpace - i : i + 1; + + const explorerItemIndex = + firstItem.item.index + + (dragDirection.y === 'down' ? -i : i) * grid.columnCount; + + const item = grid.getItem(explorerItemIndex); + if (!item?.data) return; + + // Set start row if not already set + if (!drag.current && i === itemsInEmptySpace - 1) { + addedRows.add(item.row); + } + + if (inputEvent.shiftKey) { + if (explorer.selectedItems.has(item.data)) { + explorer.removeSelectedItem(item.data); + } else { + explorer.addSelectedItem(item.data); + } + + return; + } + + if (!isItemInDrag) explorer.removeSelectedItem(item.data); + else explorer.addSelectedItem(item.data); + }); + }); + + const addedColumnsArray = [...addedColumns]; + const removedColumnsArray = [...removedColumns]; + + // Sort added rows in drag direction in case we add a row + // from the empty column drag space + const addedRowsArray = [...addedRows].sort((a, b) => { + if (dragDirection.y === 'up') return b - a; + return a - b; + }); + + const lastAddedColumn = addedColumnsArray[addedColumnsArray.length - 1]; + const lastRemovedColumn = removedColumnsArray[removedColumnsArray.length - 1]; + const lastAddedRow = addedRowsArray[addedRowsArray.length - 1]; + + const furthestAddedColumn = + dragDirection.x === 'right' ? lastAddedColumn : addedColumnsArray[0]; + + const furthestRemovedColumn = + dragDirection.x === 'right' ? lastRemovedColumn : removedColumnsArray[0]; + + let startColumn = drag.current?.startColumn; + let endColumn = drag.current?.endColumn; + let startRow = drag.current?.startRow; + let endRow = drag.current?.endRow; + + const isStartRowRemoved = startRow !== undefined && removedRows.has(startRow); + const isEndRowRemoved = endRow !== undefined && removedRows.has(endRow); + + const isStartColumnRemoved = + startColumn !== undefined && removedColumns.has(startColumn); + + // Reset drag state if we drag out of the starting point + // which isn't a selectable item + if ( + isStartRowRemoved && + isStartColumnRemoved && + !addedColumns.size && + !addedRows.size + ) { + drag.current = null; + return; + } + + // Start column + if (startColumn !== undefined && dragDirection.x === 'left') { + if (furthestAddedColumn !== undefined && furthestAddedColumn > startColumn) { + startColumn = furthestAddedColumn; + } + + if ( + isEndRowRemoved && + furthestRemovedColumn !== undefined && + startColumn <= furthestRemovedColumn + ) { + startColumn = startColumn - removedColumns.size; + } + } else if (startColumn === undefined || isStartColumnRemoved) { + startColumn = addedColumnsArray[0]; + } + + // End column + if (lastAddedColumn !== undefined) { + const isLastColumnFurther = endColumn + ? dragDirection.x === 'right' + ? lastAddedColumn > endColumn + : lastAddedColumn < endColumn + : undefined; + + if (isLastColumnFurther === undefined || isLastColumnFurther) { + endColumn = lastAddedColumn; + } + } else if (endColumn !== undefined) { + const offset = removedColumnsArray.filter((column) => column <= endColumn!).length; + endColumn += dragDirection.x === 'right' ? -[offset] : offset; + } + + // Start row + if (startRow === undefined || isStartRowRemoved) { + startRow = addedRowsArray[0] ?? endRow; + } else if (lastAddedRow !== undefined) { + const isLastRowAboveStartRow = dragDirection.y === 'up' && lastAddedRow > startRow; + startRow = isLastRowAboveStartRow ? lastAddedRow : startRow; + } + + // End row + if (lastAddedRow !== undefined) { + const isLastRowFurther = endRow + ? dragDirection.y === 'down' + ? lastAddedRow > endRow + : lastAddedRow < endRow + : undefined; + + if (isLastRowFurther === undefined || isLastRowFurther) { + endRow = lastAddedRow; + } + } else if (removedRows.size !== 0 && endRow !== undefined) { + const offset = removedRows.size; + const newEndRow = endRow + (dragDirection.y === 'down' ? -[offset] : offset); + endRow = removedRows.has(newEndRow) ? startRow : newEndRow; + } + + if ( + startColumn !== undefined && + endColumn !== undefined && + startRow !== undefined && + endRow !== undefined + ) { + drag.current = { startColumn, endColumn, startRow, endRow }; + } + } + } + + return ( + + + + {children} + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/useDragSelectable.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/useDragSelectable.tsx new file mode 100644 index 000000000..cbf268aa9 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/useDragSelectable.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; + +import { useDragSelectContext } from './context'; +import { + getElementByIndex, + SELECTABLE_DATA_ATTRIBUTE, + SELECTABLE_INDEX_DATA_ATTRIBUTE +} from './util'; + +export interface UseDragSelectableProps { + index: number; + id: string; + selected: boolean; +} + +export const useDragSelectable = (props: UseDragSelectableProps) => { + const dragSelect = useDragSelectContext(); + + const attributes = { + [SELECTABLE_DATA_ATTRIBUTE]: '', + [SELECTABLE_INDEX_DATA_ATTRIBUTE]: props.index + // [SELECTABLE_ID_DATA_ATTRIBUTE]: props.id + }; + + useEffect(() => { + const selecto = dragSelect.selecto.current; + if (!selecto) return; + + const node = getElementByIndex(props.index); + if (!node) return; + + const target = dragSelect.selectedTargets.current.get(props.id); + + if (!target && props.selected) dragSelect.addSelectedTarget(props.id, node as HTMLElement); + else if (target) { + if (!props.selected) dragSelect.removeSelectedTarget(props.id); + else if (!document.contains(target)) { + dragSelect.addSelectedTarget(props.id, node as HTMLElement); + } + } + + return () => { + if (props.selected) dragSelect.removeSelectedTarget(props.id); + }; + + // Passing the dragSelect object will just cause unnecessary re-runs + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + props.id, + props.selected, + dragSelect.selecto, + dragSelect.selectedTargets, + dragSelect.addSelectedTarget, + dragSelect.removeSelectedTarget + ]); + + return { attributes }; +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/useSelectedTargets.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/useSelectedTargets.tsx new file mode 100644 index 000000000..5d82e1e36 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/useSelectedTargets.tsx @@ -0,0 +1,35 @@ +import { RefObject, useCallback, useRef } from 'react'; +import Selecto from 'react-selecto'; + +export const useSelectedTargets = (selecto: RefObject) => { + const selectedTargets = useRef(new Map()); + + const addSelectedTarget = useCallback( + (id: string, node: HTMLElement, options = { updateSelecto: true }) => { + selectedTargets.current.set(id, node); + if (!options.updateSelecto) return; + selecto.current?.setSelectedTargets([...selectedTargets.current.values()]); + }, + [selecto] + ); + + const removeSelectedTarget = useCallback( + (id: string, options = { updateSelecto: true }) => { + selectedTargets.current.delete(id); + if (!options.updateSelecto) return; + selecto.current?.setSelectedTargets([...selectedTargets.current.values()]); + }, + [selecto] + ); + + const resetSelectedTargets = useCallback( + (targets: { id: string; node: HTMLElement }[] = [], options = { updateSelecto: true }) => { + selectedTargets.current = new Map(targets.map(({ id, node }) => [id, node])); + if (!options.updateSelecto) return; + selecto.current?.setSelectedTargets([...selectedTargets.current.values()]); + }, + [selecto] + ); + + return { selectedTargets, addSelectedTarget, removeSelectedTarget, resetSelectedTargets }; +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/util.ts b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/util.ts new file mode 100644 index 000000000..60065a477 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/util.ts @@ -0,0 +1,16 @@ +export const SELECTABLE_DATA_ATTRIBUTE = 'data-selectable'; +export const SELECTABLE_ID_DATA_ATTRIBUTE = 'data-selectable-id'; +export const SELECTABLE_INDEX_DATA_ATTRIBUTE = 'data-selectable-index'; + +export function getElementById(id: string) { + return document.querySelector(`[${SELECTABLE_ID_DATA_ATTRIBUTE}="${id}"]`); +} + +export function getElementByIndex(index: number) { + return document.querySelector(`[${SELECTABLE_INDEX_DATA_ATTRIBUTE}="${index}"]`); +} + +export function getElementIndex(element: Element) { + const index = element.getAttribute(SELECTABLE_INDEX_DATA_ATTRIBUTE); + return index ? Number(index) : null; +} diff --git a/interface/app/$libraryId/Explorer/View/Grid/Item.tsx b/interface/app/$libraryId/Explorer/View/Grid/Item.tsx index 62be65358..d66fdf4fb 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/Item.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/Item.tsx @@ -1,26 +1,28 @@ -import { HTMLAttributes, useEffect, useMemo } from 'react'; +import { HTMLAttributes, ReactNode, useMemo } from 'react'; import { useSelector, type ExplorerItem } from '@sd/client'; -import { RenderItem } from '.'; import { useExplorerContext } from '../../Context'; import { explorerStore, isCut } from '../../store'; import { uniqueId } from '../../util'; import { useExplorerViewContext } from '../Context'; -import { useGridContext } from './context'; +import { useDragSelectContext } from './DragSelect/context'; +import { useDragSelectable } from './DragSelect/useDragSelectable'; interface Props extends Omit, 'children'> { index: number; item: ExplorerItem; - children: RenderItem; + children: (state: { selected: boolean; cut: boolean }) => ReactNode; } -export const GridItem = ({ children, item, ...props }: Props) => { - const grid = useGridContext(); +export const GridItem = ({ children, item, index, ...props }: Props) => { const explorer = useExplorerContext(); const explorerView = useExplorerViewContext(); + + const dragSelect = useDragSelectContext(); + const cutCopyState = useSelector(explorerStore, (s) => s.cutCopyState); - const itemId = useMemo(() => uniqueId(item), [item]); + const cut = useMemo(() => isCut(item, cutCopyState), [cutCopyState, item]); const selected = useMemo( // Even though this checks object equality, it should still be safe since `selectedItems` @@ -29,55 +31,23 @@ export const GridItem = ({ children, item, ...props }: Props) => { [explorer.selectedItems, item] ); - const cut = useMemo(() => isCut(item, cutCopyState), [cutCopyState, item]); - - useEffect(() => { - if (!grid.selecto?.current || !grid.selectoUnselected.current.has(itemId)) return; - - if (!selected) { - grid.selectoUnselected.current.delete(itemId); - return; - } - - const element = grid.getElementById(itemId); - - if (!element) return; - - grid.selectoUnselected.current.delete(itemId); - grid.selecto.current.setSelectedTargets([ - ...grid.selecto.current.getSelectedTargets(), - element as HTMLElement - ]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!grid.selecto) return; - - return () => { - const element = grid.getElementById(itemId); - if (selected && !element) grid.selectoUnselected.current.add(itemId); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selected]); + const { attributes } = useDragSelectable({ index, id: uniqueId(item), selected }); return (
e.stopPropagation()} onContextMenu={(e) => { - if (explorerView.selectable && !explorer.selectedItems.has(item)) { - explorer.resetSelectedItems([item]); - grid.selecto?.current?.setSelectedTargets([e.currentTarget]); - } + if (!explorerView.selectable || explorer.selectedItems.has(item)) return; + explorer.resetSelectedItems([item]); + dragSelect.resetSelectedTargets([{ id: uniqueId(item), node: e.currentTarget }]); }} > - {children({ item: item, selected, cut })} + {children({ selected, cut })}
); }; diff --git a/interface/app/$libraryId/Explorer/View/Grid/context.tsx b/interface/app/$libraryId/Explorer/View/Grid/context.tsx deleted file mode 100644 index 64494527b..000000000 --- a/interface/app/$libraryId/Explorer/View/Grid/context.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createContext, useContext } from 'react'; -import Selecto from 'react-selecto'; - -interface GridContext { - selecto?: React.RefObject; - selectoUnselected: React.MutableRefObject>; - getElementById: (id: string) => Element | null | undefined; -} - -export const GridContext = createContext(null); - -export const useGridContext = () => { - const ctx = useContext(GridContext); - - if (ctx === null) throw new Error('GridContext.Provider not found!'); - - return ctx; -}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/index.tsx b/interface/app/$libraryId/Explorer/View/Grid/index.tsx deleted file mode 100644 index ec71be2af..000000000 --- a/interface/app/$libraryId/Explorer/View/Grid/index.tsx +++ /dev/null @@ -1,633 +0,0 @@ -import { Grid, useGrid } from '@virtual-grid/react'; -import { memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -import Selecto from 'react-selecto'; -import { type ExplorerItem } from '@sd/client'; -import { dialogManager } from '@sd/ui'; -import { useOperatingSystem, useShortcut } from '~/hooks'; - -import { useExplorerContext } from '../../Context'; -import { getQuickPreviewStore, useQuickPreviewStore } from '../../QuickPreview/store'; -import { explorerStore } from '../../store'; -import { uniqueId } from '../../util'; -import { useExplorerViewContext } from '../Context'; -import { GridContext } from './context'; -import { GridItem } from './Item'; - -export type RenderItem = (item: { - item: ExplorerItem; - selected: boolean; - cut: boolean; -}) => ReactNode; - -const CHROME_REGEX = /Chrome/; - -const Component = memo(({ children }: { children: RenderItem }) => { - const os = useOperatingSystem(); - const realOS = useOperatingSystem(true); - - const isChrome = CHROME_REGEX.test(navigator.userAgent); - - const explorer = useExplorerContext(); - const explorerView = useExplorerViewContext(); - const explorerSettings = explorer.useSettingsSnapshot(); - const quickPreviewStore = useQuickPreviewStore(); - - const selecto = useRef(null); - const selectoUnselected = useRef>(new Set()); - const selectoFirstColumn = useRef(); - const selectoLastColumn = useRef(); - - // The item that further selection will move from (shift + arrow for example). - // This used to be calculated from the last item of selectedItems, - // but Set ordering isn't reliable. - // Ref bc we never actually render this. - const activeItem = useRef(null); - - const [dragFromThumbnail, setDragFromThumbnail] = useState(false); - - const itemDetailsHeight = 44 + (explorerSettings.showBytesInGridView ? 20 : 0); - const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight; - const padding = explorerSettings.layoutMode === 'grid' ? 12 : 0; - - const grid = useGrid({ - scrollRef: explorer.scrollRef, - count: explorer.items?.length ?? 0, - totalCount: explorer.count, - ...(explorerSettings.layoutMode === 'grid' - ? { - columns: 'auto', - size: { width: explorerSettings.gridItemSize, height: itemHeight } - } - : { columns: explorerSettings.mediaColumns }), - rowVirtualizer: { overscan: explorer.overscan ?? 5 }, - onLoadMore: explorer.loadMore, - getItemId: useCallback( - (index: number) => { - const item = explorer.items?.[index]; - return item ? uniqueId(item) : undefined; - }, - [explorer.items] - ), - getItemData: useCallback((index: number) => explorer.items?.[index], [explorer.items]), - padding: { - bottom: explorerView.bottom ? padding + explorerView.bottom : undefined, - x: padding, - y: padding - }, - gap: explorerSettings.layoutMode === 'grid' ? explorerSettings.gridGap : 1 - }); - - const getElementById = useCallback( - (id: string) => { - if (realOS === 'windows' && explorer.parent?.type === 'Ephemeral') { - id = id.replaceAll('\\', '\\\\'); - } - - return document.querySelector(`[data-selectable-id="${id}"]`); - }, - [explorer.parent, realOS] - ); - - function getElementId(element: Element) { - return element.getAttribute('data-selectable-id'); - } - - function getElementIndex(element: Element) { - const index = element.getAttribute('data-selectable-index'); - return index ? Number(index) : null; - } - - function getElementItem(element: Element) { - const index = getElementIndex(element); - if (index === null) return null; - - return grid.getItem(index) ?? null; - } - - function getActiveItem(elements: Element[]) { - // Get selected item with least index. - // Might seem kinda weird but it's the same behaviour as Finder. - const activeItem = - elements.reduce( - (least, current) => { - const currentItem = getElementItem(current); - if (!currentItem) return least; - - if (!least) return currentItem; - - return currentItem.index < least.index ? currentItem : least; - }, - null as ReturnType - )?.data ?? null; - - return activeItem; - } - - function handleDragEnd() { - explorerStore.isDragSelecting = false; - selectoFirstColumn.current = undefined; - selectoLastColumn.current = undefined; - setDragFromThumbnail(false); - - const allSelected = selecto.current?.getSelectedTargets() ?? []; - activeItem.current = getActiveItem(allSelected); - } - - useEffect( - () => { - const element = explorer.scrollRef.current; - if (!element) return; - - const handleScroll = () => { - selecto.current?.checkScroll(); - selecto.current?.findSelectableTargets(); - }; - - element.addEventListener('scroll', handleScroll); - return () => element.removeEventListener('scroll', handleScroll); - }, - // explorer.scrollRef is a stable reference so this only actually runs once - [explorer.scrollRef] - ); - - useEffect(() => { - if (!selecto.current) return; - - const set = new Set(explorer.selectedItemHashes.value); - if (set.size === 0) return; - - const items = [...document.querySelectorAll('[data-selectable]')].filter((item) => { - const id = getElementId(item); - if (id === null) return; - - const selected = set.has(id); - if (selected) set.delete(id); - - return selected; - }); - - selectoUnselected.current = set; - selecto.current.setSelectedTargets(items as HTMLElement[]); - - activeItem.current = getActiveItem(items); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [grid.columnCount, explorer.items]); - - useEffect(() => { - if (explorer.selectedItems.size !== 0) return; - - selectoUnselected.current = new Set(); - // Accessing refs during render is bad - activeItem.current = null; - }, [explorer.selectedItems]); - - useShortcut('explorerEscape', () => { - if (!explorerView.selectable || explorer.selectedItems.size === 0) return; - explorer.resetSelectedItems([]); - selecto.current?.setSelectedTargets([]); - }); - - const keyboardHandler = (e: KeyboardEvent, newIndex: number) => { - if (!explorerView.selectable) return; - - if (explorer.selectedItems.size > 0) { - e.preventDefault(); - e.stopPropagation(); - } - - const newSelectedItem = grid.getItem(newIndex); - if (!newSelectedItem?.data) return; - - if (!explorer.allowMultiSelect) explorer.resetSelectedItems([newSelectedItem.data]); - else { - const selectedItemElement = getElementById(uniqueId(newSelectedItem.data)); - if (!selectedItemElement) return; - - if (e.shiftKey && !getQuickPreviewStore().open) { - if (!explorer.selectedItems.has(newSelectedItem.data)) { - explorer.addSelectedItem(newSelectedItem.data); - selecto.current?.setSelectedTargets([ - ...(selecto.current?.getSelectedTargets() || []), - selectedItemElement as HTMLElement - ]); - } - } else { - explorer.resetSelectedItems([newSelectedItem.data]); - selecto.current?.setSelectedTargets([selectedItemElement as HTMLElement]); - if (selectoUnselected.current.size > 0) selectoUnselected.current = new Set(); - } - } - - activeItem.current = newSelectedItem.data; - - if (!explorer.scrollRef.current || !explorerView.ref.current) return; - - const { top: viewTop } = explorerView.ref.current.getBoundingClientRect(); - - const itemTop = newSelectedItem.rect.top + viewTop; - const itemBottom = newSelectedItem.rect.bottom + viewTop; - - const { height: scrollHeight } = explorer.scrollRef.current.getBoundingClientRect(); - - const scrollTop = - (explorerView.top ?? - parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop)) + 1; - - const scrollBottom = scrollHeight - (os !== 'windows' && os !== 'browser' ? 2 : 1); - - if (itemTop < scrollTop) { - explorer.scrollRef.current.scrollBy({ - top: - itemTop - - scrollTop - - (newSelectedItem.row === 0 ? grid.padding.top : grid.gap.y / 2) - }); - } else if (itemBottom > scrollBottom - (explorerView.bottom ?? 0)) { - explorer.scrollRef.current.scrollBy({ - top: - itemBottom - - scrollBottom + - (explorerView.bottom ?? 0) + - (newSelectedItem.row === grid.rowCount - 1 - ? grid.padding.bottom - : grid.gap.y / 2) - }); - } - }; - - const getGridItemHandler = (key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') => { - const lastItem = activeItem.current; - if (!lastItem) return; - - const lastItemIndex = explorer.items?.findIndex((item) => item === lastItem); - if (lastItemIndex === undefined || lastItemIndex === -1) return; - - const gridItem = grid.getItem(lastItemIndex); - if (!gridItem) return; - - let newIndex = gridItem.index; - - switch (key) { - case 'ArrowUp': - newIndex -= grid.columnCount; - break; - case 'ArrowDown': - newIndex += grid.columnCount; - break; - case 'ArrowLeft': - newIndex -= 1; - break; - case 'ArrowRight': - newIndex += 1; - break; - } - - return newIndex; - }; - - useShortcut('explorerDown', (e) => { - if (!explorerView.selectable || dialogManager.isAnyDialogOpen()) return; - - if (explorer.selectedItems.size === 0) { - const item = grid.getItem(0); - if (!item?.data) return; - - const selectedItemElement = getElementById(uniqueId(item.data)); - if (!selectedItemElement) return; - - explorer.resetSelectedItems([item.data]); - selecto.current?.setSelectedTargets([selectedItemElement as HTMLElement]); - activeItem.current = item.data; - return; - } - - const newIndex = getGridItemHandler('ArrowDown'); - if (newIndex === undefined) return; - keyboardHandler(e, newIndex); - }); - - useShortcut('explorerUp', (e) => { - if (!explorerView.selectable || dialogManager.isAnyDialogOpen()) return; - const newIndex = getGridItemHandler('ArrowUp'); - if (newIndex === undefined) return; - keyboardHandler(e, newIndex); - }); - - useShortcut('explorerLeft', (e) => { - if (!explorerView.selectable || dialogManager.isAnyDialogOpen()) return; - const newIndex = getGridItemHandler('ArrowLeft'); - if (newIndex === undefined) return; - keyboardHandler(e, newIndex); - }); - - useShortcut('explorerRight', (e) => { - if (!explorerView.selectable || dialogManager.isAnyDialogOpen()) return; - const newIndex = getGridItemHandler('ArrowRight'); - if (newIndex === undefined) return; - keyboardHandler(e, newIndex); - }); - - //everytime selected items change within quick preview we need to update selecto - useEffect(() => { - if (!selecto.current || !quickPreviewStore.open) return; - if (explorer.selectedItems.size !== 1) return; - - const [item] = Array.from(explorer.selectedItems); - if (!item) return; - - const itemId = uniqueId(item); - - const element = getElementById(itemId); - - if (!element) selectoUnselected.current = new Set(itemId); - else selecto.current.setSelectedTargets([element as HTMLElement]); - - activeItem.current = item; - }, [explorer.items, explorer.selectedItems, quickPreviewStore.open, realOS, getElementById]); - - return ( - - {explorer.allowMultiSelect && ( - { - if (!explorerStore.drag) return; - e.stop(); - handleDragEnd(); - }} - onDragStart={({ inputEvent }) => { - explorerStore.isDragSelecting = true; - - if ((inputEvent as MouseEvent).target instanceof HTMLImageElement) { - setDragFromThumbnail(true); - } - }} - onDragEnd={handleDragEnd} - onScroll={({ direction }) => { - selecto.current?.findSelectableTargets(); - explorer.scrollRef.current?.scrollBy( - (direction[0] || 0) * 10, - (direction[1] || 0) * 10 - ); - }} - scrollOptions={{ - container: { current: explorer.scrollRef.current }, - throttleTime: isChrome || dragFromThumbnail ? 30 : 10000 - }} - onSelect={(e) => { - const inputEvent = e.inputEvent as MouseEvent; - - if (inputEvent.type === 'mousedown') { - const el = inputEvent.shiftKey - ? e.added[0] || e.removed[0] - : e.selected[0]; - - if (!el) return; - - const item = getElementItem(el); - - if (!item?.data) return; - - if (!inputEvent.shiftKey) { - if (explorer.selectedItems.has(item.data)) { - selecto.current?.setSelectedTargets(e.beforeSelected); - } else { - selectoUnselected.current = new Set(); - explorer.resetSelectedItems([item.data]); - } - - return; - } - - if (e.added[0]) explorer.addSelectedItem(item.data); - else explorer.removeSelectedItem(item.data); - } else if (inputEvent.type === 'mousemove') { - const unselectedItems: string[] = []; - - e.added.forEach((el) => { - const item = getElementItem(el); - - if (!item?.data) return; - - explorer.addSelectedItem(item.data); - }); - - e.removed.forEach((el) => { - const item = getElementItem(el); - - if (!item?.data || typeof item.id === 'number') return; - - if (document.contains(el)) explorer.removeSelectedItem(item.data); - else unselectedItems.push(item.id); - }); - - const dragDirection = { - x: inputEvent.x === e.rect.left ? 'left' : 'right', - y: inputEvent.y === e.rect.bottom ? 'down' : 'up' - } as const; - - const dragStart = { - x: dragDirection.x === 'right' ? e.rect.left : e.rect.right, - y: dragDirection.y === 'down' ? e.rect.top : e.rect.bottom - }; - - const dragEnd = { x: inputEvent.x, y: inputEvent.y }; - - const columns = new Set(); - - const elements = [...e.added, ...e.removed]; - - const items = elements.reduce( - (items, el) => { - const item = getElementItem(el); - - if (!item) return items; - - columns.add(item.column); - return [...items, item]; - }, - [] as NonNullable>[] - ); - - if (columns.size > 1) { - items.sort((a, b) => a.column - b.column); - - const firstItem = - dragDirection.x === 'right' - ? items[0] - : items[items.length - 1]; - - const lastItem = - dragDirection.x === 'right' - ? items[items.length - 1] - : items[0]; - - if (firstItem && lastItem) { - selectoFirstColumn.current = firstItem.column; - selectoLastColumn.current = lastItem.column; - } - } else if (columns.size === 1) { - const column = [...columns.values()][0]!; - - items.sort((a, b) => a.row - b.row); - - const itemRect = elements[0]?.getBoundingClientRect(); - - const inDragArea = - itemRect && - (dragDirection.x === 'right' - ? dragEnd.x >= itemRect.left - : dragEnd.x <= itemRect.right); - - if ( - column !== selectoLastColumn.current || - (column === selectoLastColumn.current && !inDragArea) - ) { - const firstItem = - dragDirection.y === 'down' - ? items[0] - : items[items.length - 1]; - - if (firstItem) { - const viewRectTop = - explorerView.ref.current?.getBoundingClientRect().top ?? - 0; - - const itemTop = firstItem.rect.top + viewRectTop; - const itemBottom = firstItem.rect.bottom + viewRectTop; - - if ( - dragDirection.y === 'down' - ? dragStart.y < itemTop - : dragStart.y > itemBottom - ) { - const dragHeight = Math.abs( - dragStart.y - - (dragDirection.y === 'down' - ? itemTop - : itemBottom) - ); - - let itemsInDragCount = - (dragHeight - grid.gap.y) / - (grid.virtualItemSize.height + grid.gap.y); - - if (itemsInDragCount > 1) { - itemsInDragCount = Math.ceil(itemsInDragCount); - } else { - itemsInDragCount = Math.round(itemsInDragCount); - } - - [...Array(itemsInDragCount)].forEach((_, i) => { - const index = - dragDirection.y === 'down' - ? itemsInDragCount - i - : i + 1; - - const itemIndex = - firstItem.index + - (dragDirection.y === 'down' ? -index : index) * - grid.columnCount; - - const item = explorer.items?.[itemIndex]; - - if (item) { - if (inputEvent.shiftKey) { - if (explorer.selectedItems.has(item)) - explorer.removeSelectedItem(item); - else { - explorer.addSelectedItem(item); - if (inDragArea) - unselectedItems.push( - uniqueId(item) - ); - } - } else if (!inDragArea) - explorer.removeSelectedItem(item); - else { - explorer.addSelectedItem(item); - if (inDragArea) - unselectedItems.push(uniqueId(item)); - } - } - }); - } - } - - if (!inDragArea && column === selectoFirstColumn.current) { - selectoFirstColumn.current = undefined; - selectoLastColumn.current = undefined; - } else { - selectoLastColumn.current = column; - if (selectoFirstColumn.current === undefined) { - selectoFirstColumn.current = column; - } - } - } - } - - if (unselectedItems.length > 0) { - selectoUnselected.current = new Set([ - ...selectoUnselected.current, - ...unselectedItems - ]); - } - } - }} - /> - )} - - - {(index) => { - const item = explorer.items?.[index]; - if (!item) return null; - - return ( - { - if (e.button !== 0 || !explorerView.selectable) return; - - e.stopPropagation(); - - const item = grid.getItem(index); - - if (!item?.data) return; - - if (!explorer.allowMultiSelect) { - explorer.resetSelectedItems([item.data]); - } else { - selectoFirstColumn.current = item.column; - selectoLastColumn.current = item.column; - } - - activeItem.current = item.data; - }} - > - {children} - - ); - }} - - - ); -}); - -Component.displayName = 'Grid'; - -export default Component; diff --git a/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx b/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx new file mode 100644 index 000000000..493de6047 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx @@ -0,0 +1,152 @@ +import { useGrid } from '@virtual-grid/react'; +import { useEffect, useRef } from 'react'; +import { ExplorerItem } from '@sd/client'; +import { useShortcut } from '~/hooks'; + +import { useExplorerContext } from '../../Context'; +import { useQuickPreviewStore } from '../../QuickPreview/store'; +import { uniqueId } from '../../util'; +import { useExplorerViewContext } from '../Context'; + +type Grid = ReturnType>; + +interface Options { + /** + * Whether to scroll to the start/end of the grid on first/last row. + * @default false + */ + scrollToEnd?: boolean; +} + +export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: false }) => { + const explorer = useExplorerContext(); + const explorerView = useExplorerViewContext(); + const quickPreview = useQuickPreviewStore(); + + // The item that further selection will move from (shift + arrow for example). + const activeItem = useRef(null); + + // The index of the active item. This is stored so we don't have to look + // for the index every time we want to move to the next item. + const activeItemIndex = useRef(null); + + useEffect(() => { + if (quickPreview.open) return; + activeItem.current = [...explorer.selectedItems][0] ?? null; + }, [explorer.selectedItems, quickPreview.open]); + + useEffect(() => { + activeItemIndex.current = null; + }, [explorer.items, explorer.selectedItems]); + + const scrollToItem = (item: NonNullable>) => { + if (!explorer.scrollRef.current || !explorerView.ref.current) return; + + const { top: viewTop } = explorerView.ref.current.getBoundingClientRect(); + const { height: scrollHeight } = explorer.scrollRef.current.getBoundingClientRect(); + + const itemTop = item.rect.top + viewTop; + const itemBottom = item.rect.bottom + viewTop; + + const scrollTop = explorerView.scrollPadding?.top ?? 0; + const scrollBottom = scrollHeight - (explorerView.scrollPadding?.bottom ?? 0); + + // Handle scroll when item is above viewport + if (itemTop < scrollTop) { + const offset = !item.row + ? (options.scrollToEnd && (grid.padding.top ?? 0)) || 0 + : (grid.gap.y ?? 0) / 2; + + explorer.scrollRef.current.scrollBy({ top: itemTop - scrollTop - offset }); + + return; + } + + // Handle scroll when item is bellow viewport + if (itemBottom > scrollBottom) { + const offset = + item.row === grid.rowCount - 1 + ? (options.scrollToEnd && (grid.padding.bottom ?? 0)) || 0 + : (grid.gap.y ?? 0) / 2; + + explorer.scrollRef.current.scrollBy({ top: itemBottom - scrollBottom + offset }); + } + }; + + const handleNavigation = (e: KeyboardEvent, direction: 'up' | 'down' | 'left' | 'right') => { + if (!explorerView.selectable) return; + + e.preventDefault(); + e.stopPropagation(); + + // Select first item in grid if no items are selected, on down/right keybind + // TODO: Handle when no items are selected and up/left keybind is executed (should select last item in grid) + if ((direction === 'down' || direction === 'right') && explorer.selectedItems.size === 0) { + const item = grid.getItem(0); + if (!item?.data) return; + + explorer.resetSelectedItems([item.data]); + scrollToItem(item); + + return; + } + + let currentItemIndex = activeItemIndex.current; + + // Find current index if we don't have the index stored + if (currentItemIndex === null) { + const currentItem = activeItem.current; + if (!currentItem) return; + + const index = explorer.items?.findIndex( + (item) => uniqueId(item) === uniqueId(currentItem) + ); + + if (index === undefined || index === -1) return; + + currentItemIndex = index; + } + + if (currentItemIndex === null) return; + + let newIndex = currentItemIndex; + + switch (direction) { + case 'up': + newIndex -= grid.columnCount; + break; + case 'down': + newIndex += grid.columnCount; + break; + case 'left': + newIndex -= 1; + break; + case 'right': + newIndex += 1; + } + + const newSelectedItem = grid.getItem(newIndex); + if (!newSelectedItem?.data) return; + + if (!e.shiftKey) { + explorer.resetSelectedItems([newSelectedItem.data]); + } else if (!explorer.isItemSelected(newSelectedItem.data)) { + explorer.addSelectedItem(newSelectedItem.data); + } + + // Timeout so useEffects don't override it + setTimeout(() => { + activeItem.current = newSelectedItem.data!; + activeItemIndex.current = newIndex; + }); + + scrollToItem(newSelectedItem); + }; + + useShortcut('explorerUp', (e) => handleNavigation(e, 'up')); + useShortcut('explorerDown', (e) => handleNavigation(e, 'down')); + useShortcut('explorerLeft', (e) => handleNavigation(e, 'left')); + useShortcut('explorerRight', (e) => handleNavigation(e, 'right')); + + return { activeItem }; +}; diff --git a/interface/app/$libraryId/Explorer/View/GridView/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/index.tsx index 8d4c24bf0..82f069426 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/index.tsx @@ -1,12 +1,71 @@ -import Grid from '../Grid'; +import { Grid, useGrid } from '@virtual-grid/react'; +import { useCallback } from 'react'; + +import { useExplorerContext } from '../../Context'; +import { getItemData, getItemId, uniqueId } from '../../util'; +import { useExplorerViewContext } from '../Context'; +import { DragSelect } from '../Grid/DragSelect'; +import { GridItem } from '../Grid/Item'; +import { useKeySelection } from '../Grid/useKeySelection'; import { GridViewItem } from './Item'; +const PADDING = 12; + export const GridView = () => { + const explorer = useExplorerContext(); + const explorerView = useExplorerViewContext(); + const explorerSettings = explorer.useSettingsSnapshot(); + + const itemDetailsHeight = 44 + (explorerSettings.showBytesInGridView ? 20 : 0); + const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight; + + const grid = useGrid({ + scrollRef: explorer.scrollRef, + count: explorer.items?.length ?? 0, + totalCount: explorer.count, + columns: 'auto', + size: { width: explorerSettings.gridItemSize, height: itemHeight }, + padding: { + bottom: PADDING + (explorerView.scrollPadding?.bottom ?? 0), + x: PADDING, + y: PADDING + }, + gap: explorerSettings.gridGap, + overscan: explorer.overscan ?? 5, + onLoadMore: explorer.loadMore, + getItemId: useCallback( + (index: number) => getItemId(index, explorer.items ?? []), + [explorer.items] + ), + getItemData: useCallback( + (index: number) => getItemData(index, explorer.items ?? []), + [explorer.items] + ) + }); + + const { activeItem } = useKeySelection(grid, { scrollToEnd: true }); + return ( - - {({ item, selected, cut }) => ( - - )} - + (activeItem.current = item)}> + + {(index) => { + const item = explorer.items?.[index]; + if (!item) return null; + + return ( + + {({ selected, cut }) => ( + + )} + + ); + }} + + ); }; diff --git a/interface/app/$libraryId/Explorer/View/ListView/index.tsx b/interface/app/$libraryId/Explorer/View/ListView/index.tsx index 55e38d630..9e74374c2 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/index.tsx @@ -64,7 +64,7 @@ export const ListView = memo(() => { getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]), estimateSize: useCallback(() => ROW_HEIGHT, []), paddingStart: TABLE_PADDING_Y, - paddingEnd: TABLE_PADDING_Y + (explorerView.bottom ?? 0), + paddingEnd: TABLE_PADDING_Y + (explorerView.scrollPadding?.bottom ?? 0), scrollMargin: listOffset, overscan: explorer.overscan ?? 10 }); @@ -81,46 +81,44 @@ export const ListView = memo(() => { const rowIndex = row.index; const item = row.original; - if (explorer.allowMultiSelect) { - if (e.shiftKey) { - const range = getRangeByIndex(ranges.length - 1); + if (e.shiftKey) { + const range = getRangeByIndex(ranges.length - 1); - if (!range) { - const items = [...Array(rowIndex + 1)].reduce((items, _, i) => { - const item = rows[i]?.original; - if (item) return [...items, item]; - return items; - }, []); + if (!range) { + const items = [...Array(rowIndex + 1)].reduce((items, _, i) => { + const item = rows[i]?.original; + if (item) return [...items, item]; + return items; + }, []); - const [rangeStart] = items; + const [rangeStart] = items; - if (rangeStart) { - setRanges([[uniqueId(rangeStart), uniqueId(item)]]); - } - - explorer.resetSelectedItems(items); - return; + if (rangeStart) { + setRanges([[uniqueId(rangeStart), uniqueId(item)]]); } - const direction = getRangeDirection(range.end.index, rowIndex); + explorer.resetSelectedItems(items); + return; + } - if (!direction) return; + const direction = getRangeDirection(range.end.index, rowIndex); - const changeDirection = - !!range.direction && - range.direction !== direction && - (direction === 'down' - ? rowIndex > range.start.index - : rowIndex < range.start.index); + if (!direction) return; - let _ranges = ranges; + const changeDirection = + !!range.direction && + range.direction !== direction && + (direction === 'down' + ? rowIndex > range.start.index + : rowIndex < range.start.index); - const [backRange, frontRange] = getRangesByRow(range.start); + let _ranges = ranges; - if (backRange && frontRange) { - [ - ...Array(backRange.sorted.end.index - backRange.sorted.start.index + 1) - ].forEach((_, i) => { + const [backRange, frontRange] = getRangesByRow(range.start); + + if (backRange && frontRange) { + [...Array(backRange.sorted.end.index - backRange.sorted.start.index + 1)].forEach( + (_, i) => { const index = backRange.sorted.start.index + i; if (index === range.start.index) return; @@ -128,14 +126,14 @@ export const ListView = memo(() => { const row = rows[index]; if (row) explorer.removeSelectedItem(row.original); - }); + } + ); - _ranges = _ranges.filter((_, i) => i !== backRange.index); - } + _ranges = _ranges.filter((_, i) => i !== backRange.index); + } - [ - ...Array(Math.abs(range.end.index - rowIndex) + (changeDirection ? 1 : 0)) - ].forEach((_, i) => { + [...Array(Math.abs(range.end.index - rowIndex) + (changeDirection ? 1 : 0))].forEach( + (_, i) => { if (!range.direction || direction === range.direction) i += 1; const index = range.end.index + (direction === 'down' ? i : -i); @@ -158,192 +156,186 @@ export const ListView = memo(() => { ) { explorer.addSelectedItem(item); } else explorer.removeSelectedItem(item); - }); - - let newRangeEnd = item; - let removeRangeIndex: number | null = null; - - for (let i = 0; i < _ranges.length - 1; i++) { - const range = getRangeByIndex(i); - - if (!range) continue; - - if ( - rowIndex >= range.sorted.start.index && - rowIndex <= range.sorted.end.index - ) { - const removableRowsCount = Math.abs( - (direction === 'down' - ? range.sorted.end.index - : range.sorted.start.index) - rowIndex - ); - - [...Array(removableRowsCount)].forEach((_, i) => { - i += 1; - - const index = rowIndex + (direction === 'down' ? i : -i); - - const row = rows[index]; - - if (row) explorer.removeSelectedItem(row.original); - }); - - removeRangeIndex = i; - break; - } else if (direction === 'down' && rowIndex + 1 === range.sorted.start.index) { - newRangeEnd = range.sorted.end.original; - removeRangeIndex = i; - break; - } else if (direction === 'up' && rowIndex - 1 === range.sorted.end.index) { - newRangeEnd = range.sorted.start.original; - removeRangeIndex = i; - break; - } } + ); - if (removeRangeIndex !== null) { - _ranges = _ranges.filter((_, i) => i !== removeRangeIndex); + let newRangeEnd = item; + let removeRangeIndex: number | null = null; + + for (let i = 0; i < _ranges.length - 1; i++) { + const range = getRangeByIndex(i); + + if (!range) continue; + + if (rowIndex >= range.sorted.start.index && rowIndex <= range.sorted.end.index) { + const removableRowsCount = Math.abs( + (direction === 'down' ? range.sorted.end.index : range.sorted.start.index) - + rowIndex + ); + + [...Array(removableRowsCount)].forEach((_, i) => { + i += 1; + + const index = rowIndex + (direction === 'down' ? i : -i); + + const row = rows[index]; + + if (row) explorer.removeSelectedItem(row.original); + }); + + removeRangeIndex = i; + break; + } else if (direction === 'down' && rowIndex + 1 === range.sorted.start.index) { + newRangeEnd = range.sorted.end.original; + removeRangeIndex = i; + break; + } else if (direction === 'up' && rowIndex - 1 === range.sorted.end.index) { + newRangeEnd = range.sorted.start.original; + removeRangeIndex = i; + break; } + } - setRanges([ - ..._ranges.slice(0, _ranges.length - 1), - [uniqueId(range.start.original), uniqueId(newRangeEnd)] - ]); - } else if (e.metaKey) { - if (explorer.selectedItems.has(item)) { - explorer.removeSelectedItem(item); + if (removeRangeIndex !== null) { + _ranges = _ranges.filter((_, i) => i !== removeRangeIndex); + } - const rowRanges = getRangesByRow(row); + setRanges([ + ..._ranges.slice(0, _ranges.length - 1), + [uniqueId(range.start.original), uniqueId(newRangeEnd)] + ]); + } else if (e.metaKey) { + if (explorer.selectedItems.has(item)) { + explorer.removeSelectedItem(item); - const range = rowRanges[0] || rowRanges[1]; + const rowRanges = getRangesByRow(row); - if (range) { - const rangeStart = range.sorted.start.original; - const rangeEnd = range.sorted.end.original; + const range = rowRanges[0] || rowRanges[1]; - if (rangeStart === rangeEnd) { - const closestRange = getClosestRange(range.index); - if (closestRange) { - const _ranges = ranges.filter( - (_, i) => i !== closestRange.index && i !== range.index - ); + if (range) { + const rangeStart = range.sorted.start.original; + const rangeEnd = range.sorted.end.original; - const start = closestRange.sorted.start.original; - const end = closestRange.sorted.end.original; - - setRanges([ - ..._ranges, - [ - uniqueId(closestRange.direction === 'down' ? start : end), - uniqueId(closestRange.direction === 'down' ? end : start) - ] - ]); - } else { - setRanges([]); - } - } else if (rangeStart === item || rangeEnd === item) { + if (rangeStart === rangeEnd) { + const closestRange = getClosestRange(range.index); + if (closestRange) { const _ranges = ranges.filter( - (_, i) => i !== range.index && i !== rowRanges[1]?.index + (_, i) => i !== closestRange.index && i !== range.index ); - const start = - rows[ - rangeStart === item - ? range.sorted.start.index + 1 - : range.sorted.end.index - 1 - ]?.original; - - if (start !== undefined) { - const end = rangeStart === item ? rangeEnd : rangeStart; - - setRanges([..._ranges, [uniqueId(start), uniqueId(end)]]); - } - } else { - const rowBefore = rows[row.index - 1]; - const rowAfter = rows[row.index + 1]; - - if (rowBefore && rowAfter) { - const firstRange = [ - uniqueId(rangeStart), - uniqueId(rowBefore.original) - ] satisfies Range; - - const secondRange = [ - uniqueId(rowAfter.original), - uniqueId(rangeEnd) - ] satisfies Range; - - const _ranges = ranges.filter( - (_, i) => i !== range.index && i !== rowRanges[1]?.index - ); - - setRanges([..._ranges, firstRange, secondRange]); - } - } - } - } else { - explorer.addSelectedItem(item); - - const itemRange: Range = [uniqueId(item), uniqueId(item)]; - - const _ranges = [...ranges, itemRange]; - - const rangeDown = getClosestRange(_ranges.length - 1, { - direction: 'down', - maxRowDifference: 0, - ranges: _ranges - }); - - const rangeUp = getClosestRange(_ranges.length - 1, { - direction: 'up', - maxRowDifference: 0, - ranges: _ranges - }); - - if (rangeDown && rangeUp) { - const _ranges = ranges.filter( - (_, i) => i !== rangeDown.index && i !== rangeUp.index - ); - - setRanges([ - ..._ranges, - [ - uniqueId(rangeUp.sorted.start.original), - uniqueId(rangeDown.sorted.end.original) - ], - itemRange - ]); - } else if (rangeUp || rangeDown) { - const closestRange = rangeDown || rangeUp; - - if (closestRange) { - const _ranges = ranges.filter((_, i) => i !== closestRange.index); + const start = closestRange.sorted.start.original; + const end = closestRange.sorted.end.original; setRanges([ ..._ranges, [ - uniqueId(item), - uniqueId( - closestRange.direction === 'down' - ? closestRange.sorted.end.original - : closestRange.sorted.start.original - ) + uniqueId(closestRange.direction === 'down' ? start : end), + uniqueId(closestRange.direction === 'down' ? end : start) ] ]); + } else { + setRanges([]); + } + } else if (rangeStart === item || rangeEnd === item) { + const _ranges = ranges.filter( + (_, i) => i !== range.index && i !== rowRanges[1]?.index + ); + + const start = + rows[ + rangeStart === item + ? range.sorted.start.index + 1 + : range.sorted.end.index - 1 + ]?.original; + + if (start !== undefined) { + const end = rangeStart === item ? rangeEnd : rangeStart; + + setRanges([..._ranges, [uniqueId(start), uniqueId(end)]]); } } else { - setRanges([...ranges, itemRange]); + const rowBefore = rows[row.index - 1]; + const rowAfter = rows[row.index + 1]; + + if (rowBefore && rowAfter) { + const firstRange = [ + uniqueId(rangeStart), + uniqueId(rowBefore.original) + ] satisfies Range; + + const secondRange = [ + uniqueId(rowAfter.original), + uniqueId(rangeEnd) + ] satisfies Range; + + const _ranges = ranges.filter( + (_, i) => i !== range.index && i !== rowRanges[1]?.index + ); + + setRanges([..._ranges, firstRange, secondRange]); + } } } } else { - if (explorer.isItemSelected(item)) return; + explorer.addSelectedItem(item); - explorer.resetSelectedItems([item]); - const hash = uniqueId(item); - setRanges([[hash, hash]]); + const itemRange: Range = [uniqueId(item), uniqueId(item)]; + + const _ranges = [...ranges, itemRange]; + + const rangeDown = getClosestRange(_ranges.length - 1, { + direction: 'down', + maxRowDifference: 0, + ranges: _ranges + }); + + const rangeUp = getClosestRange(_ranges.length - 1, { + direction: 'up', + maxRowDifference: 0, + ranges: _ranges + }); + + if (rangeDown && rangeUp) { + const _ranges = ranges.filter( + (_, i) => i !== rangeDown.index && i !== rangeUp.index + ); + + setRanges([ + ..._ranges, + [ + uniqueId(rangeUp.sorted.start.original), + uniqueId(rangeDown.sorted.end.original) + ], + itemRange + ]); + } else if (rangeUp || rangeDown) { + const closestRange = rangeDown || rangeUp; + + if (closestRange) { + const _ranges = ranges.filter((_, i) => i !== closestRange.index); + + setRanges([ + ..._ranges, + [ + uniqueId(item), + uniqueId( + closestRange.direction === 'down' + ? closestRange.sorted.end.original + : closestRange.sorted.start.original + ) + ] + ]); + } + } else { + setRanges([...ranges, itemRange]); + } } } else { + if (explorer.isItemSelected(item)) return; + explorer.resetSelectedItems([item]); + const hash = uniqueId(item); + setRanges([[hash, hash]]); } }; @@ -367,7 +359,7 @@ export const ListView = memo(() => { const tableTop = scrollRect.top + - (explorerView.top ?? + (explorerView.scrollPadding?.top ?? parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop)) + (explorer.scrollRef.current.scrollTop > top ? 36 : 0); @@ -382,11 +374,11 @@ export const ListView = memo(() => { if (rowTop < tableTop) { const scrollBy = rowTop - tableTop - (row.index === 0 ? TABLE_PADDING_Y : 0); explorer.scrollRef.current.scrollBy({ top: scrollBy }); - } else if (rowBottom > scrollRect.height - (explorerView.bottom ?? 0)) { + } else if (rowBottom > scrollRect.height - (explorerView.scrollPadding?.bottom ?? 0)) { const scrollBy = rowBottom - scrollRect.height + - (explorerView.bottom ?? 0) + + (explorerView.scrollPadding?.bottom ?? 0) + (row.index === rows.length - 1 ? TABLE_PADDING_Y : 0); explorer.scrollRef.current.scrollBy({ top: scrollBy }); @@ -394,8 +386,8 @@ export const ListView = memo(() => { }, [ explorer.scrollRef, - explorerView.bottom, - explorerView.top, + explorerView.scrollPadding?.bottom, + explorerView.scrollPadding?.top, rowVirtualizer.options.paddingStart, rows.length, top @@ -428,130 +420,120 @@ export const ListView = memo(() => { const item = nextRow.original; - if (explorer.allowMultiSelect) { - if (e.shiftKey && !getQuickPreviewStore().open) { - const direction = range.direction || keyDirection; + if (e.shiftKey && !getQuickPreviewStore().open) { + const direction = range.direction || keyDirection; - const [backRange, frontRange] = getRangesByRow(range.start); + const [backRange, frontRange] = getRangesByRow(range.start); - if ( - range.direction - ? keyDirection !== range.direction - : backRange?.direction && - (backRange.sorted.start.index === frontRange?.sorted.start.index || - backRange.sorted.end.index === frontRange?.sorted.end.index) - ) { - explorer.removeSelectedItem(range.end.original); + if ( + range.direction + ? keyDirection !== range.direction + : backRange?.direction && + (backRange.sorted.start.index === frontRange?.sorted.start.index || + backRange.sorted.end.index === frontRange?.sorted.end.index) + ) { + explorer.removeSelectedItem(range.end.original); - if (backRange && frontRange) { - let _ranges = [...ranges]; + if (backRange && frontRange) { + let _ranges = [...ranges]; - _ranges[backRange.index] = [ - uniqueId( - backRange.direction !== keyDirection - ? backRange.start.original - : nextRow.original - ), - uniqueId( - backRange.direction !== keyDirection - ? nextRow.original - : backRange.end.original - ) - ]; + _ranges[backRange.index] = [ + uniqueId( + backRange.direction !== keyDirection + ? backRange.start.original + : nextRow.original + ), + uniqueId( + backRange.direction !== keyDirection + ? nextRow.original + : backRange.end.original + ) + ]; - if ( - nextRow.index === backRange.start.index || - nextRow.index === backRange.end.index - ) { - _ranges = _ranges.filter((_, i) => i !== frontRange.index); - } else { - _ranges[frontRange.index] = - frontRange.start.index === frontRange.end.index - ? [uniqueId(nextRow.original), uniqueId(nextRow.original)] - : [ - uniqueId(frontRange.start.original), - uniqueId(nextRow.original) - ]; - } - - setRanges(_ranges); + if ( + nextRow.index === backRange.start.index || + nextRow.index === backRange.end.index + ) { + _ranges = _ranges.filter((_, i) => i !== frontRange.index); } else { - setRanges([ - ...ranges.slice(0, ranges.length - 1), - [uniqueId(range.start.original), uniqueId(nextRow.original)] - ]); + _ranges[frontRange.index] = + frontRange.start.index === frontRange.end.index + ? [uniqueId(nextRow.original), uniqueId(nextRow.original)] + : [uniqueId(frontRange.start.original), uniqueId(nextRow.original)]; } + + setRanges(_ranges); } else { - explorer.addSelectedItem(item); - - let rangeEndRow = nextRow; - - const closestRange = getClosestRange(range.index, { - maxRowDifference: 1, - direction - }); - - if (closestRange) { - rangeEndRow = - direction === 'down' - ? closestRange.sorted.end - : closestRange.sorted.start; - } - - if (backRange && frontRange) { - let _ranges = [...ranges]; - - const backRangeStart = backRange.start.original; - - const backRangeEnd = - rangeEndRow.index < backRange.sorted.start.index || - rangeEndRow.index > backRange.sorted.end.index - ? rangeEndRow.original - : backRange.end.original; - - _ranges[backRange.index] = [ - uniqueId(backRangeStart), - uniqueId(backRangeEnd) - ]; - - if ( - backRange.direction !== direction && - (rangeEndRow.original === backRangeStart || - rangeEndRow.original === backRangeEnd) - ) { - _ranges[backRange.index] = - rangeEndRow.original === backRangeStart - ? [uniqueId(backRangeEnd), uniqueId(backRangeStart)] - : [uniqueId(backRangeStart), uniqueId(backRangeEnd)]; - } - - _ranges[frontRange.index] = [ - uniqueId(frontRange.start.original), - uniqueId(rangeEndRow.original) - ]; - - if (closestRange) { - _ranges = _ranges.filter((_, i) => i !== closestRange.index); - } - - setRanges(_ranges); - } else { - const _ranges = closestRange - ? ranges.filter((_, i) => i !== closestRange.index && i !== range.index) - : ranges; - - setRanges([ - ..._ranges.slice(0, _ranges.length - 1), - [uniqueId(range.start.original), uniqueId(rangeEndRow.original)] - ]); - } + setRanges([ + ...ranges.slice(0, ranges.length - 1), + [uniqueId(range.start.original), uniqueId(nextRow.original)] + ]); } } else { - explorer.resetSelectedItems([item]); - const hash = uniqueId(item); - setRanges([[hash, hash]]); + explorer.addSelectedItem(item); + + let rangeEndRow = nextRow; + + const closestRange = getClosestRange(range.index, { + maxRowDifference: 1, + direction + }); + + if (closestRange) { + rangeEndRow = + direction === 'down' ? closestRange.sorted.end : closestRange.sorted.start; + } + + if (backRange && frontRange) { + let _ranges = [...ranges]; + + const backRangeStart = backRange.start.original; + + const backRangeEnd = + rangeEndRow.index < backRange.sorted.start.index || + rangeEndRow.index > backRange.sorted.end.index + ? rangeEndRow.original + : backRange.end.original; + + _ranges[backRange.index] = [uniqueId(backRangeStart), uniqueId(backRangeEnd)]; + + if ( + backRange.direction !== direction && + (rangeEndRow.original === backRangeStart || + rangeEndRow.original === backRangeEnd) + ) { + _ranges[backRange.index] = + rangeEndRow.original === backRangeStart + ? [uniqueId(backRangeEnd), uniqueId(backRangeStart)] + : [uniqueId(backRangeStart), uniqueId(backRangeEnd)]; + } + + _ranges[frontRange.index] = [ + uniqueId(frontRange.start.original), + uniqueId(rangeEndRow.original) + ]; + + if (closestRange) { + _ranges = _ranges.filter((_, i) => i !== closestRange.index); + } + + setRanges(_ranges); + } else { + const _ranges = closestRange + ? ranges.filter((_, i) => i !== closestRange.index && i !== range.index) + : ranges; + + setRanges([ + ..._ranges.slice(0, _ranges.length - 1), + [uniqueId(range.start.original), uniqueId(rangeEndRow.original)] + ]); + } } - } else explorer.resetSelectedItems([item]); + } else { + explorer.resetSelectedItems([item]); + const hash = uniqueId(item); + setRanges([[hash, hash]]); + } scrollToRow(nextRow); }; @@ -744,7 +726,7 @@ export const ListView = memo(() => { const observer = new MutationObserver(() => { setTop( - explorerView.top ?? + explorerView.scrollPadding?.top ?? parseInt(getComputedStyle(element).paddingTop) + element.getBoundingClientRect().top ); @@ -757,7 +739,7 @@ export const ListView = memo(() => { }); return () => observer.disconnect(); - }, [explorer.scrollRef, explorerView.top]); + }, [explorer.scrollRef, explorerView.scrollPadding?.top]); // Set list offset useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []); diff --git a/interface/app/$libraryId/Explorer/View/MediaView/DateHeader.tsx b/interface/app/$libraryId/Explorer/View/MediaView/DateHeader.tsx new file mode 100644 index 000000000..e609d0f8f --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/MediaView/DateHeader.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; +import { useIsDark } from '~/hooks'; + +import { useExplorerContext } from '../../Context'; +import { useExplorerViewContext } from '../Context'; + +export const DATE_HEADER_HEIGHT = 140; + +// million-ignore +export const DateHeader = ({ date }: { date: string }) => { + const isDark = useIsDark(); + + const explorer = useExplorerContext(); + const view = useExplorerViewContext(); + + const ref = useRef(null); + + const [isSticky, setIsSticky] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node) return; + + const scroll = explorer.scrollRef.current; + if (!scroll) return; + + // We add the top of the explorer scroll because of the custom border/frame on desktop + const rootMarginTop = (view.scrollPadding?.top ?? 0) + scroll.getBoundingClientRect().top; + + const observer = new IntersectionObserver( + ([entry]) => entry && setIsSticky(!entry.isIntersecting), + { rootMargin: `-${rootMarginTop}px 0px 0px 0px`, threshold: [1] } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [explorer.scrollRef, view.scrollPadding?.top]); + + return ( +
+
+
{date}
+
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx index 83d44d66e..3aff67cb4 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx @@ -1,20 +1,227 @@ +import { LoadMoreTrigger, useGrid, useScrollMargin, useVirtualizer } from '@virtual-grid/react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { getExplorerItemData } from '@sd/client'; + import { useExplorerContext } from '../../Context'; -import Grid from '../Grid'; +import { getOrderingDirection, orderingKey } from '../../store'; +import { getItemData, getItemId, uniqueId } from '../../util'; +import { useExplorerViewContext } from '../Context'; +import { DragSelect } from '../Grid/DragSelect'; +import { GridItem } from '../Grid/Item'; +import { useKeySelection } from '../Grid/useKeySelection'; +import { DATE_HEADER_HEIGHT, DateHeader } from './DateHeader'; import { MediaViewItem } from './Item'; +import { formatDate } from './util'; + +const SORT_BY_DATE_KEYS = [ + 'dateCreated', + 'dateIndexed', + 'dateModified', + 'object.dateAccessed', + 'object.mediaData.epochTime' +]; export const MediaView = () => { - const explorerSettings = useExplorerContext().useSettingsSnapshot(); + const explorer = useExplorerContext(); + const explorerView = useExplorerViewContext(); + const explorerSettings = explorer.useSettingsSnapshot(); + + const gridRef = useRef(null); + + const orderBy = explorerSettings.order && orderingKey(explorerSettings.order); + const orderDirection = explorerSettings.order && getOrderingDirection(explorerSettings.order); + + const isSortingByDate = orderBy && SORT_BY_DATE_KEYS.includes(orderBy); + + const grid = useGrid({ + scrollRef: explorer.scrollRef, + count: explorer.items?.length ?? 0, + totalCount: explorer.count, + columns: explorerSettings.mediaColumns, + padding: { + top: isSortingByDate ? DATE_HEADER_HEIGHT : 0, + bottom: explorerView.scrollPadding?.bottom + }, + gap: 1, + overscan: explorer.overscan ?? 5, + onLoadMore: explorer.loadMore, + getItemId: useCallback( + (index: number) => getItemId(index, explorer.items ?? []), + [explorer.items] + ), + getItemData: useCallback( + (index: number) => getItemData(index, explorer.items ?? []), + [explorer.items] + ) + }); + + const { scrollMargin } = useScrollMargin({ scrollRef: explorer.scrollRef, gridRef }); + + const rowVirtualizer = useVirtualizer({ + ...grid.rowVirtualizer, + scrollMargin: scrollMargin.top + }); + + const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); + + useEffect(() => { + rowVirtualizer.measure(); + columnVirtualizer.measure(); + }, [rowVirtualizer, columnVirtualizer, grid.virtualItemHeight]); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + const date = useMemo(() => { + if (!isSortingByDate || !orderBy || !orderDirection) return; + + let firstRowIndex: number | undefined = undefined; + let lastRowIndex: number | undefined = undefined; + + // Find first row in viewport + for (let i = 0; i < virtualRows.length; i++) { + const row = virtualRows[i]!; + if (row.end >= rowVirtualizer.scrollOffset) { + firstRowIndex = row.index; + break; + } + } + + // Find last row in viewport + for (let i = virtualRows.length - 1; i >= 0; i--) { + const row = virtualRows[i]!; + if (row.start <= rowVirtualizer.scrollOffset + rowVirtualizer.scrollRect.height) { + lastRowIndex = row.index; + break; + } + } + + if (firstRowIndex === undefined || lastRowIndex === undefined) return; + + // Get the index of the last item and exclude any total count indexes + let lastItemIndex = lastRowIndex * grid.columnCount + grid.columnCount; + if (lastItemIndex > grid.options.count - 1) lastItemIndex = grid.options.count - 1; + + const firstExplorerItem = explorer.items?.[firstRowIndex * grid.columnCount]; + const lastExplorerItem = explorer.items?.[lastItemIndex]; + + const firstFilePath = firstExplorerItem && getExplorerItemData(firstExplorerItem); + if (!firstFilePath) return; + + const lastFilePath = lastExplorerItem && getExplorerItemData(lastExplorerItem); + if (!lastFilePath) return; + + let firstFilePathDate: string | null = null; + let lastFilePathDate: string | null = null; + + switch (orderBy) { + case 'dateCreated': { + firstFilePathDate = firstFilePath.dateCreated; + lastFilePathDate = lastFilePath.dateCreated; + break; + } + + case 'dateIndexed': { + firstFilePathDate = firstFilePath.dateIndexed; + lastFilePathDate = lastFilePath.dateIndexed; + break; + } + + case 'dateModified': { + firstFilePathDate = firstFilePath.dateModified; + lastFilePathDate = lastFilePath.dateModified; + break; + } + + case 'object.dateAccessed': { + firstFilePathDate = firstFilePath.dateAccessed; + lastFilePathDate = lastFilePath.dateAccessed; + break; + } + + // TODO: Uncomment when we add sorting by date taken + // case 'object.mediaData.epochTime': { + // firstFilePathDate = firstFilePath.dateTaken; + // lastFilePathDate = lastFilePath.dateTaken; + // break; + // } + } + + const firstDate = firstFilePathDate + ? new Date(new Date(firstFilePathDate).setHours(0, 0, 0, 0)) + : undefined; + + const lastDate = lastFilePathDate + ? new Date(new Date(lastFilePathDate).setHours(0, 0, 0, 0)) + : undefined; + + if (!firstDate || !lastDate) return; + + if (firstDate.getTime() !== lastDate.getTime()) { + return formatDate({ + from: orderDirection === 'Asc' ? firstDate : lastDate, + to: orderDirection === 'Asc' ? lastDate : firstDate + }); + } + + return formatDate(firstDate); + }, [ + explorer.items, + grid.columnCount, + grid.options.count, + isSortingByDate, + rowVirtualizer.scrollOffset, + rowVirtualizer.scrollRect.height, + virtualRows, + orderBy, + orderDirection + ]); + + const { activeItem } = useKeySelection(grid); return ( - - {({ item, selected, cut }) => ( - - )} - +
+ {isSortingByDate && } + + (activeItem.current = item)}> + {virtualRows.map((virtualRow) => ( + + {columnVirtualizer.getVirtualItems().map((virtualColumn) => { + const virtualItem = grid.getVirtualItem({ + row: virtualRow, + column: virtualColumn, + scrollMargin + }); + + const item = virtualItem && explorer.items?.[virtualItem.index]; + if (!item) return null; + + return ( +
+ + {({ selected, cut }) => ( + + )} + +
+ ); + })} +
+ ))} +
+ + +
); }; diff --git a/interface/app/$libraryId/Explorer/View/MediaView/util.ts b/interface/app/$libraryId/Explorer/View/MediaView/util.ts new file mode 100644 index 000000000..42302b441 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/MediaView/util.ts @@ -0,0 +1,16 @@ +import dayjs from 'dayjs'; + +const DATE_FORMAT = 'D MMM YYYY'; + +export const formatDate = (date: Date | { from: Date; to: Date }) => { + if (date instanceof Date) return dayjs(date).format(DATE_FORMAT); + + const sameMonth = date.from.getMonth() === date.to.getMonth(); + const sameYear = date.from.getFullYear() === date.to.getFullYear(); + + const fromDateFormat = ['D', !sameMonth && 'MMM', !sameYear && 'YYYY'] + .filter(Boolean) + .join(' '); + + return `${dayjs(date.from).format(fromDateFormat)} - ${dayjs(date.to).format(DATE_FORMAT)}`; +}; diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 4a86e8afa..9347e84d3 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -83,6 +83,11 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { useShortcuts(); + useShortcut('explorerEscape', () => { + if (!selectable || explorer.selectedItems.size === 0) return; + explorer.resetSelectedItems([]); + }); + useEffect(() => { if (!visible || !isContextMenuOpen || explorer.selectedItems.size !== 0) return; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index fb8ce23bb..ae094db67 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -104,7 +104,10 @@ export default function Explorer(props: PropsWithChildren) { ) } listViewOptions={{ hideHeaderBorder: true }} - bottom={showPathBar ? PATH_BAR_HEIGHT : undefined} + scrollPadding={{ + top: topBar.topBarHeight, + bottom: showPathBar ? PATH_BAR_HEIGHT : undefined + }} />
diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 66b28f2e1..0f05da136 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -42,10 +42,6 @@ export interface UseExplorerProps { isFetchingNextPage?: boolean; isLoadingPreferences?: boolean; scrollRef?: RefObject; - /** - * @defaultValue `true` - */ - allowMultiSelect?: boolean; overscan?: number; /** * @defaultValue `true` @@ -72,7 +68,6 @@ export function useExplorer({ return { // Default values - allowMultiSelect: true, selectable: true, scrollRef, count: props.items?.length, diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index ce46fb310..eeef7d99d 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -50,3 +50,12 @@ export const uniqueId = (item: ExplorerItem | { pub_id: number[] }) => { return pubIdToString(item.item.pub_id); } }; + +export function getItemId(index: number, items: ExplorerItem[]) { + const item = items[index]; + return item ? uniqueId(item) : undefined; +} + +export function getItemData(index: number, items: ExplorerItem[]) { + return items[index]; +} diff --git a/interface/package.json b/interface/package.json index d4fd68da6..f26b7458a 100644 --- a/interface/package.json +++ b/interface/package.json @@ -32,7 +32,7 @@ "@tanstack/react-table": "^8.10.7", "@tanstack/react-virtual": "3.0.0-beta.66", "@total-typescript/ts-reset": "^0.5.1", - "@virtual-grid/react": "^1.1.0", + "@virtual-grid/react": "^2.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "crypto-random-string": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cacab753659ab362effde652b27ea5f6270e71c0..d310ebb1a4e602140c7abffb355587e923096190 100644 GIT binary patch delta 1218 zcmYjQOKjV899HVYo|`p$l|5jfYFkw;P7^zgleE*cI!T>(6DPLw5JPpH#CGDOc|R%+ ztVmQ7hY14xUxu^|#8x%|6Vg18IDmu@2gG*b0vbaT8%09nfW%=EcmtLFr0?+ielO|M z@B8lSxesp5eSCA;Kz#D)G--pJc6?}n#y4&lZ9~PzytHR=38mF|)o6r{qF!GYg0hR_PM7!`A#8Jbqg1go&k02dJ&GN`3 z%)n}~)tYw!X47N`OA0&zZpGl;+K&zx#-FO;+R-I={RsGIFEXZSYp~r4e&2-Q;qDwV zGCu<@c8$kC{T<^Z7p4<*C&jUX5_b%g<@R8(yhvBg%O$Qfc zzVFYbnVOJu_bOR$C&IUj^n$O!aY0;2$2 zgU7-3^Tq_QybGhlUn^$q;Wc>22)KX6JjtbV*@TZRN0xE2nH8K}l_XiVmvG^fyBF=? zDN-RCOizyF76aji5DwCw03K?^dRRR|4HRdhTWhNeVZPJq&-B~N%Tk(C+^fSst{!@F zP5b3T_}f+R^A=*|@(sD&;e&BL?w!pzyh&Ou2OC0$$4lObSj75n@4zKzvUSNHQss27 z9&A$41yblqL81`~w3b;hj#s*b81s6g2|81$E!PH_;l+(ZAZkVS8JysabH*?y_SCq_ z79wmG^U3N0k(8wvBV|@Ym7KriaXRz>wHCn+r3>lx)U(Qa9PCSXJ`L5DzcLa4>YAOXs zOBR~N!qnV^6a4f8Gzq@J5OPnUSgcQrE7iJQo0}l)&f)FCbKvij@CoqwS@SV)+J;Pk zxD_$O|Bi#ZTL{K2;FV^pUTyPe8(OZl6G()m|ge1KY_>CrTPdh~wb)W#|w%eAKuLByrTF9d@DK$4zI>!Y9GR zm!tpD?~~{Y+p~Av=s`Wv{_>#QHYv}d@`z6Arx2sQfH?qLAA4*MIKK(G{$B-NoJa3H zaV?N2^n1NiH}z!$v3}o&>KN5w*0EE&?;Csm!QQ78pVtMq6X5O)G{Z&MS|U$1@lu0O z`ffkt%2JCfnXrt{^eH7yt4W8-Q@KEaO5}T;rn}bgr3(~WBOJ_ZG8PVoGnn7guhMy+ zUlb@;G!_tLfKDBe#4b1W;30`JKQE*4JJfmG&vbZ4ao@n{) F)IV?Yn0x>L delta 694 zcmY+CUr1AN6vw&yxqEk=-UMT4`44gEyw1DnhTC$@;ikprpJ{h`N1=0byYp`9{6Fa- zHev+P%cagk5cUV8h%kbFR!|03kLpuUkG_&l8NIefox&Uv!!*!tvH zTQ?G>7q5)gCOMR?kgId`a-u1a%ZBpV5TT&L*$}$zMJ7W71Ul(5aBd(Y9$7&j2*QN* z56~4IggXlA@#A&$o`4@uDJNcije6bkuBgis2S}j+69?*OtD>9%<}y+6PGu z-sZ^P+(`qYQSwHuyfvqVw*o`qds))ZD1Vo*J2Swtc3<~I z(wi2SxrOOusy`wQjCD;V<@Iz=V#gAa(E~Z@<-yK%;Z9kJz`tp zFG_uUA~uuu2wc2JV5P7vG$zdl`V!u5>5@-KrZd(hURvyo^_nediS_$aF>jlV&jdXd z`#^0`(@L4$Lxw4Q*6yhaE6R+_|{7Fu^)W2JTwDw6?rR#7>; z1Fc%#ds%omrp7`WZ6jc#S_w1l6xJS#-)wZ%KQA?|aL{FkSGS&{`M;hk?Q}|Y5~s!u zE_x3g`QVOD+DRP!20Zf6pa1(H4czn6*9lX;u8USERM09E;kvHk%3VCdF}JMX5}6XL R9cL^E_M%J`?h~1>u0P)_?0^6O