mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 13:26:00 -04:00
[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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
544
interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx
Normal file
544
interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
152
interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx
Normal file
152
interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx
Normal 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 };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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), []);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
16
interface/app/$libraryId/Explorer/View/MediaView/util.ts
Normal file
16
interface/app/$libraryId/Explorer/View/MediaView/util.ts
Normal 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)}`;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user