[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
This commit is contained in:
nikec
2024-02-12 18:43:26 +01:00
committed by GitHub
parent edbf3363cb
commit 76ce21dbbd
23 changed files with 1543 additions and 1043 deletions

View File

@@ -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;

View File

@@ -2,8 +2,10 @@ import { createContext, useContext, type ReactNode, type RefObject } from 'react
export interface ExplorerViewContext {
ref: RefObject<HTMLDivElement>;
top?: number;
bottom?: number;
/**
* Padding to apply when scrolling to an item.
*/
scrollPadding?: { top?: number; bottom?: number };
contextMenu?: ReactNode;
selectable: boolean;
listViewOptions?: {

View File

@@ -0,0 +1,17 @@
import { HTMLAttributes, PropsWithChildren } from 'react';
import { useDragSelectable, UseDragSelectableProps } from './useDragSelectable';
interface DragSelectableProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
selectable: UseDragSelectableProps;
}
export const DragSelectable = ({ children, selectable, ...props }: DragSelectableProps) => {
const { attributes } = useDragSelectable(selectable);
return (
<div {...props} {...attributes}>
{children}
</div>
);
};

View File

@@ -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<typeof useSelectedTargets> {
selecto: React.RefObject<Selecto>;
drag: React.MutableRefObject<Drag | null>;
}
export const DragSelectContext = createContext<DragSelectContext | null>(null);
export const useDragSelectContext = () => {
const ctx = useContext(DragSelectContext);
if (ctx === null) throw new Error('DragSelectContext.Provider not found!');
return ctx;
};

View File

@@ -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<typeof useGrid<string, ExplorerItem | undefined>>;
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<Selecto>(null);
const drag = useRef<Drag | null>(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<ReturnType<typeof getGridItem>>; 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<number>();
const removedColumns = new Set<number>();
const addedRows = new Set<number>();
const removedRows = new Set<number>();
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 (
<DragSelectContext.Provider value={{ selecto, drag, ...selectedTargets }}>
<Selecto
ref={selecto}
dragContainer={explorerView.ref.current}
boundContainer={{
element: explorerView.ref.current ?? false,
top: false,
bottom: false
}}
scrollOptions={{
container: { current: explorer.scrollRef.current },
throttleTime: isChrome ? 30 : 10000
}}
selectableTargets={[`[${SELECTABLE_DATA_ATTRIBUTE}]`]}
toggleContinueSelect="shift"
hitRate={0}
onDrag={handleDrag}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onScroll={handleScroll}
onSelect={handleSelect}
/>
{children}
</DragSelectContext.Provider>
);
};

View File

@@ -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 };
};

View File

@@ -0,0 +1,35 @@
import { RefObject, useCallback, useRef } from 'react';
import Selecto from 'react-selecto';
export const useSelectedTargets = (selecto: RefObject<Selecto>) => {
const selectedTargets = useRef(new Map<string, HTMLElement>());
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 };
};

View File

@@ -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;
}

View File

@@ -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<HTMLAttributes<HTMLDivElement>, '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 (
<div
{...props}
{...attributes}
className="h-full w-full"
data-selectable=""
data-selectable-index={props.index}
data-selectable-id={itemId}
// Prevent explorer view onMouseDown event from
// being executed and resetting the selection
onMouseDown={(e) => 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 })}
</div>
);
};

View File

@@ -1,18 +0,0 @@
import { createContext, useContext } from 'react';
import Selecto from 'react-selecto';
interface GridContext {
selecto?: React.RefObject<Selecto>;
selectoUnselected: React.MutableRefObject<Set<string>>;
getElementById: (id: string) => Element | null | undefined;
}
export const GridContext = createContext<GridContext | null>(null);
export const useGridContext = () => {
const ctx = useContext(GridContext);
if (ctx === null) throw new Error('GridContext.Provider not found!');
return ctx;
};

View File

@@ -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<Selecto>(null);
const selectoUnselected = useRef<Set<string>>(new Set());
const selectoFirstColumn = useRef<number | undefined>();
const selectoLastColumn = useRef<number | undefined>();
// 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<ExplorerItem | null>(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<typeof getElementItem>
)?.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 (
<GridContext.Provider value={{ selecto, selectoUnselected, getElementById }}>
{explorer.allowMultiSelect && (
<Selecto
ref={selecto}
boundContainer={
explorerView.ref.current
? {
element: explorerView.ref.current,
top: false,
bottom: false
}
: undefined
}
selectableTargets={['[data-selectable]']}
toggleContinueSelect="shift"
hitRate={0}
onDrag={(e) => {
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<number>();
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<ReturnType<typeof getElementItem>>[]
);
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
]);
}
}
}}
/>
)}
<Grid grid={grid}>
{(index) => {
const item = explorer.items?.[index];
if (!item) return null;
return (
<GridItem
key={uniqueId(item)}
index={index}
item={item}
onMouseDown={(e) => {
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}
</GridItem>
);
}}
</Grid>
</GridContext.Provider>
);
});
Component.displayName = 'Grid';
export default Component;

View File

@@ -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<typeof useGrid<string, ExplorerItem | undefined>>;
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<ExplorerItem | null>(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<number | null>(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<ReturnType<Grid['getItem']>>) => {
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 };
};

View File

@@ -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 (
<Grid>
{({ item, selected, cut }) => (
<GridViewItem data={item} selected={selected} cut={cut} />
)}
</Grid>
<DragSelect grid={grid} onActiveItemChange={(item) => (activeItem.current = item)}>
<Grid grid={grid}>
{(index) => {
const item = explorer.items?.[index];
if (!item) return null;
return (
<GridItem
key={uniqueId(item)}
index={index}
item={item}
style={{ width: grid.itemWidth }}
>
{({ selected, cut }) => (
<GridViewItem data={item} selected={selected} cut={cut} />
)}
</GridItem>
);
}}
</Grid>
</DragSelect>
);
};

View File

@@ -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<ExplorerItem[]>((items, _, i) => {
const item = rows[i]?.original;
if (item) return [...items, item];
return items;
}, []);
if (!range) {
const items = [...Array(rowIndex + 1)].reduce<ExplorerItem[]>((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), []);

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
style={{ height: DATE_HEADER_HEIGHT }}
className={clsx(
'pointer-events-none sticky inset-x-0 -top-px z-10 p-5 transition-colors duration-500',
!isSticky && !isDark ? 'text-ink' : 'text-white'
)}
>
<div
className={clsx(
'absolute inset-0 bg-gradient-to-b from-black/60 to-transparent transition-opacity duration-500',
isSticky ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="relative text-xl font-semibold">{date}</div>
</div>
);
};

View File

@@ -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<HTMLDivElement>(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 (
<Grid>
{({ item, selected, cut }) => (
<MediaViewItem
data={item}
selected={selected}
cut={cut}
cover={explorerSettings.mediaAspectSquare}
/>
)}
</Grid>
<div
ref={gridRef}
style={{
position: 'relative',
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%'
}}
>
{isSortingByDate && <DateHeader date={date ?? ''} />}
<DragSelect grid={grid} onActiveItemChange={(item) => (activeItem.current = item)}>
{virtualRows.map((virtualRow) => (
<React.Fragment key={virtualRow.key}>
{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 (
<div key={uniqueId(item)} style={virtualItem.style}>
<GridItem index={virtualItem.index} item={item}>
{({ selected, cut }) => (
<MediaViewItem
data={item}
selected={selected}
cover={explorerSettings.mediaAspectSquare}
cut={cut}
/>
)}
</GridItem>
</div>
);
})}
</React.Fragment>
))}
</DragSelect>
<LoadMoreTrigger {...grid.getLoadMoreTrigger({ virtualizer: rowVirtualizer })} />
</div>
);
};

View File

@@ -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)}`;
};

View File

@@ -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;

View File

@@ -104,7 +104,10 @@ export default function Explorer(props: PropsWithChildren<Props>) {
)
}
listViewOptions={{ hideHeaderBorder: true }}
bottom={showPathBar ? PATH_BAR_HEIGHT : undefined}
scrollPadding={{
top: topBar.topBarHeight,
bottom: showPathBar ? PATH_BAR_HEIGHT : undefined
}}
/>
</div>
</ExplorerContextMenu>

View File

@@ -42,10 +42,6 @@ export interface UseExplorerProps<TOrder extends Ordering> {
isFetchingNextPage?: boolean;
isLoadingPreferences?: boolean;
scrollRef?: RefObject<HTMLDivElement>;
/**
* @defaultValue `true`
*/
allowMultiSelect?: boolean;
overscan?: number;
/**
* @defaultValue `true`
@@ -72,7 +68,6 @@ export function useExplorer<TOrder extends Ordering>({
return {
// Default values
allowMultiSelect: true,
selectable: true,
scrollRef,
count: props.items?.length,

View File

@@ -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];
}

View File

@@ -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",

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.