From e3d69fe1b5adeb189eb262956746d3f630afabc3 Mon Sep 17 00:00:00 2001 From: nikec <43032218+niikeec@users.noreply.github.com> Date: Sun, 24 Sep 2023 14:16:38 +0200 Subject: [PATCH] [ENG-1114] List view improvements (#1371) * almost there * so close * fix drag scroll * rerender * Update index.tsx * fix y scroll * scroll * header border * remove react-scroll-sync --- .../app/$libraryId/Explorer/View/GridList.tsx | 2 +- .../app/$libraryId/Explorer/View/GridView.tsx | 2 +- .../app/$libraryId/Explorer/View/ListView.tsx | 1277 ----------------- .../Explorer/View/ListView/index.tsx | 993 +++++++++++++ .../Explorer/View/ListView/util/ranges.tsx | 158 ++ .../Explorer/View/ListView/util/table.tsx | 184 +++ .../$libraryId/Explorer/View/MediaView.tsx | 2 +- .../app/$libraryId/Explorer/View/ViewItem.tsx | 175 +++ .../app/$libraryId/Explorer/View/index.tsx | 283 +--- .../app/$libraryId/Explorer/ViewContext.ts | 5 +- interface/app/$libraryId/Explorer/index.tsx | 1 + .../app/$libraryId/Explorer/useExplorer.ts | 4 + interface/app/$libraryId/overview/index.tsx | 2 +- interface/package.json | 4 +- interface/util/index.tsx | 1 + packages/client/src/utils/explorerItem.ts | 7 +- pnpm-lock.yaml | 37 +- 17 files changed, 1614 insertions(+), 1523 deletions(-) delete mode 100644 interface/app/$libraryId/Explorer/View/ListView.tsx create mode 100644 interface/app/$libraryId/Explorer/View/ListView/index.tsx create mode 100644 interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx create mode 100644 interface/app/$libraryId/Explorer/View/ListView/util/table.tsx create mode 100644 interface/app/$libraryId/Explorer/View/ViewItem.tsx diff --git a/interface/app/$libraryId/Explorer/View/GridList.tsx b/interface/app/$libraryId/Explorer/View/GridList.tsx index 638a04492..b7cb3eab5 100644 --- a/interface/app/$libraryId/Explorer/View/GridList.tsx +++ b/interface/app/$libraryId/Explorer/View/GridList.tsx @@ -142,7 +142,7 @@ export default ({ children }: { children: RenderItem }) => { [explorer.items] ), getItemData: useCallback((index: number) => explorer.items?.[index], [explorer.items]), - padding: explorerView.padding || settings.layoutMode === 'grid' ? 12 : undefined, + padding: explorerView.padding ?? settings.layoutMode === 'grid' ? 12 : undefined, gap: explorerView.gap || (settings.layoutMode === 'grid' ? explorerStore.gridGap : undefined), diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx index a639ca232..e757be131 100644 --- a/interface/app/$libraryId/Explorer/View/GridView.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView.tsx @@ -2,13 +2,13 @@ import clsx from 'clsx'; import { memo } from 'react'; import { byteSize, getItemFilePath, getItemLocation, type ExplorerItem } from '@sd/client'; -import { ViewItem } from '.'; import { useExplorerContext } from '../Context'; import { FileThumb } from '../FilePath/Thumb'; import { useQuickPreviewStore } from '../QuickPreview/store'; import { useExplorerViewContext } from '../ViewContext'; import GridList from './GridList'; import RenamableItemText from './RenamableItemText'; +import { ViewItem } from './ViewItem'; interface GridViewItemProps { data: ExplorerItem; diff --git a/interface/app/$libraryId/Explorer/View/ListView.tsx b/interface/app/$libraryId/Explorer/View/ListView.tsx deleted file mode 100644 index 92201f068..000000000 --- a/interface/app/$libraryId/Explorer/View/ListView.tsx +++ /dev/null @@ -1,1277 +0,0 @@ -import { CaretDown, CaretUp } from '@phosphor-icons/react'; -import { - flexRender, - functionalUpdate, - getCoreRowModel, - useReactTable, - VisibilityState, - type ColumnDef, - type ColumnSizingState, - type Row -} from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import clsx from 'clsx'; -import dayjs from 'dayjs'; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; -import { useKey, useMutationObserver, useWindowEventListener } from 'rooks'; -import useResizeObserver from 'use-resize-observer'; -import { - byteSize, - getExplorerItemData, - getItemFilePath, - getItemLocation, - getItemObject, - type ExplorerItem, - type FilePath, - type NonIndexedPathItem -} from '@sd/client'; -import { ContextMenu, Tooltip } from '@sd/ui'; -import { useIsTextTruncated, useScrolled } from '~/hooks'; -import { stringify } from '~/util/uuid'; - -import { ViewItem } from '.'; -import { useLayoutContext } from '../../Layout/Context'; -import { useExplorerContext } from '../Context'; -import { FileThumb } from '../FilePath/Thumb'; -import { InfoPill } from '../Inspector'; -import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store'; -import { - createOrdering, - getOrderingDirection, - isCut, - orderingKey, - useExplorerStore -} from '../store'; -import { uniqueId } from '../util'; -import { useExplorerViewContext } from '../ViewContext'; -import RenamableItemText from './RenamableItemText'; - -interface ListViewItemProps { - row: Row; - columnSizing: ColumnSizingState; - columnVisibility?: VisibilityState; - paddingX: number; - selected: boolean; - cut: boolean; -} - -const ListViewItem = memo((props: ListViewItemProps) => { - return ( - -
- {props.row.getVisibleCells().map((cell) => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
-
- ); -}); - -const HeaderColumnName = ({ name }: { name: string }) => { - const textRef = useRef(null); - - const isTruncated = useIsTextTruncated(textRef, name); - - return ( -
- {isTruncated ? ( - - {name} - - ) : ( - {name} - )} -
- ); -}; - -type Range = [string, string]; - -export default () => { - const layout = useLayoutContext(); - const explorer = useExplorerContext(); - const explorerStore = useExplorerStore(); - const explorerView = useExplorerViewContext(); - const settings = explorer.useSettingsSnapshot(); - - const quickPreviewStore = useQuickPreviewStore(); - - const tableRef = useRef(null); - const tableHeaderRef = useRef(null); - const tableBodyRef = useRef(null); - - const [sized, setSized] = useState(false); - const [locked, setLocked] = useState(false); - const [resizing, setResizing] = useState(false); - const [columnSizing, setColumnSizing] = useState({}); - const [columnVisibility, setColumnVisibility] = useState(); - const [listOffset, setListOffset] = useState(0); - const [ranges, setRanges] = useState([]); - - const top = - (explorerView.top || 0) + - (explorer.scrollRef.current - ? parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop) - : 0); - - const { isScrolled } = useScrolled(explorer.scrollRef, sized ? listOffset - top : undefined); - - const paddingX = - (typeof explorerView.padding === 'object' - ? explorerView.padding.x - : explorerView.padding) || 16; - - const paddingY = - (typeof explorerView.padding === 'object' - ? explorerView.padding.y - : explorerView.padding) || 12; - - const scrollBarWidth = 8; - const rowHeight = 45; - const { width: tableWidth = 0 } = useResizeObserver({ ref: tableRef }); - const { width: headerWidth = 0 } = useResizeObserver({ ref: tableHeaderRef }); - - const getFileName = (path: FilePath | NonIndexedPathItem) => - `${path.name}${path.extension && `.${path.extension}`}`; - - useEffect(() => { - //we need this to trigger a re-render with the updated column sizes from the store - if (!resizing) { - setColumnSizing(explorer.settingsStore.colSizes); - } - }, [resizing, explorer.settingsStore.colSizes]); - - const columns = useMemo[]>( - () => [ - { - id: 'name', - header: 'Name', - minSize: 200, - size: settings.colSizes['name'], - maxSize: undefined, - meta: { className: '!overflow-visible !text-ink' }, - accessorFn: (file) => { - const locationData = getItemLocation(file); - const filePathData = getItemFilePath(file); - return locationData - ? locationData.name - : filePathData && getFileName(filePathData); - }, - cell: (cell) => { - const item = cell.row.original; - - const selected = explorer.selectedItems.has(cell.row.original); - - const cut = isCut(item, explorerStore.cutCopyState); - - return ( -
-
- -
- 1 || - quickPreviewStore.open - } - style={{ maxHeight: 36 }} - /> -
- ); - } - }, - { - id: 'kind', - header: 'Type', - size: settings.colSizes['kind'], - enableSorting: false, - accessorFn: (file) => getExplorerItemData(file).kind, - cell: (cell) => ( - - {getExplorerItemData(cell.row.original).kind} - - ) - }, - { - id: 'sizeInBytes', - header: 'Size', - size: settings.colSizes['sizeInBytes'], - accessorFn: (file) => { - const file_path = getItemFilePath(file); - if (!file_path || !file_path.size_in_bytes_bytes) return; - - return byteSize(file_path.size_in_bytes_bytes); - } - }, - { - id: 'dateCreated', - header: 'Date Created', - size: settings.colSizes['dateCreated'], - accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY') - }, - { - id: 'dateModified', - header: 'Date Modified', - size: settings.colSizes['dateModified'], - accessorFn: (file) => - dayjs(getItemFilePath(file)?.date_modified).format('MMM Do YYYY') - }, - { - id: 'dateIndexed', - header: 'Date Indexed', - accessorFn: (file) => { - const item = getItemFilePath(file); - return dayjs( - (item && 'date_indexed' in item && item.date_indexed) || null - ).format('MMM Do YYYY'); - } - }, - { - id: 'dateAccessed', - header: 'Date Accessed', - size: settings.colSizes['dateAccessed'], - accessorFn: (file) => - getItemObject(file)?.date_accessed && - dayjs(getItemObject(file)?.date_accessed).format('MMM Do YYYY') - }, - { - id: 'contentId', - header: 'Content ID', - enableSorting: false, - size: settings.colSizes['contentId'], - accessorFn: (file) => getExplorerItemData(file).casId - }, - { - id: 'objectId', - header: 'Object ID', - enableSorting: false, - size: 180, - accessorFn: (file) => { - const value = getItemObject(file)?.pub_id; - if (!value) return null; - return stringify(value); - } - } - ], - [ - settings.colSizes, - explorer.selectedItems, - explorerStore.cutCopyState, - quickPreviewStore.open - ] - ); - - const table = useReactTable({ - data: useMemo(() => explorer.items ?? [], [explorer.items]), - columns, - defaultColumn: { minSize: 100, maxSize: 250 }, - state: { columnSizing, columnVisibility }, - onColumnVisibilityChange: (updater) => { - setColumnVisibility(functionalUpdate(updater, columnVisibility ?? {})); - }, - onColumnSizingChange: setColumnSizing, - columnResizeMode: 'onChange', - getCoreRowModel: useMemo(() => getCoreRowModel(), []), - getRowId: uniqueId - }); - - const rows = table.getRowModel().rows; - const tableLength = table.getTotalSize(); - - const rowVirtualizer = useVirtualizer({ - count: explorer.count ?? rows.length, - getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]), - estimateSize: useCallback(() => rowHeight, []), - paddingStart: paddingY + (isScrolled ? 35 : 0), - paddingEnd: paddingY, - scrollMargin: listOffset - }); - - const virtualRows = rowVirtualizer.getVirtualItems(); - - function isSelected(item: ExplorerItem) { - return explorer.selectedItems.has(item); - } - - function getRangeDirection(start: number, end: number) { - return start < end ? ('down' as const) : start > end ? ('up' as const) : null; - } - - function getRangeByIndex(index: number) { - const range = ranges[index]; - - if (!range) return; - - const rangeRows = getRangeRows(range); - - if (!rangeRows) return; - - const direction = getRangeDirection(rangeRows.start.index, rangeRows.end.index); - - return { ...rangeRows, direction, index }; - } - - function getRangesByRow({ index }: Row) { - const _ranges = ranges.reduce>[]>( - (ranges, range, i) => { - const rangeRows = getRangeRows(range); - - if (!rangeRows) return ranges; - - if (index >= rangeRows.sorted.start.index && index <= rangeRows.sorted.end.index) { - const range = getRangeByIndex(i); - return range ? [...ranges, range] : ranges; - } - - return ranges; - }, - [] - ); - - return _ranges; - } - - function getRangeRows(range: Range) { - const { rowsById } = table.getCoreRowModel(); - - const rangeRows = range - .map((id) => rowsById[id]) - .filter((row): row is Row => Boolean(row)); - - const [start, end] = rangeRows; - - const [sortedStart, sortedEnd] = [...rangeRows].sort((a, b) => a.index - b.index); - - if (!start || !end || !sortedStart || !sortedEnd) return; - - return { start, end, sorted: { start: sortedStart, end: sortedEnd } }; - } - - function sortRanges(ranges: Range[]) { - return ranges - .map((range, i) => { - const rows = getRangeRows(range); - - if (!rows) return; - - return { - index: i, - ...rows - }; - }) - .filter( - ( - range - ): range is NonNullable> & { index: number } => - Boolean(range) - ) - .sort((a, b) => a.sorted.start.index - b.sorted.start.index); - } - - function getClosestRange( - rangeIndex: number, - options: { - direction?: 'up' | 'down'; - maxRowDifference?: number; - ranges?: Range[]; - } = {} - ) { - const range = getRangeByIndex(rangeIndex); - - let _ranges = sortRanges(options.ranges || ranges); - - if (range) { - _ranges = _ranges.filter( - (_range) => - range.index === _range.index || - range.sorted.start.index < _range.sorted.start.index || - range.sorted.end.index > _range.sorted.end.index - ); - } - - const targetRangeIndex = _ranges.findIndex(({ index }) => rangeIndex === index); - - const targetRange = _ranges[targetRangeIndex]; - - if (!targetRange) return; - - const closestRange = - options.direction === 'down' - ? _ranges[targetRangeIndex + 1] - : options.direction === 'up' - ? _ranges[targetRangeIndex - 1] - : _ranges[targetRangeIndex + 1] || _ranges[targetRangeIndex - 1]; - - if (!closestRange) return; - - const direction = options.direction || (_ranges[targetRangeIndex + 1] ? 'down' : 'up'); - - const rowDifference = - direction === 'down' - ? closestRange.sorted.start.index - 1 - targetRange.sorted.end.index - : targetRange.sorted.start.index - (closestRange.sorted.end.index + 1); - - if (options.maxRowDifference !== undefined && rowDifference > options.maxRowDifference) - return; - - return { - ...closestRange, - direction, - rowDifference - }; - } - - function handleRowClick( - e: React.MouseEvent, - row: Row - ) { - // Ensure mouse click is with left button - if (e.button !== 0) return; - - const rowIndex = row.index; - const item = row.original; - - if (explorer.allowMultiSelect) { - if (e.shiftKey) { - const { rows } = table.getCoreRowModel(); - - const range = getRangeByIndex(ranges.length - 1); - - if (!range) { - const items = [...Array(rowIndex + 1)].reduce((items, _, i) => { - const item = rows[i]?.original; - if (item) return [...items, item]; - return items; - }, []); - - const [rangeStart] = items; - - if (rangeStart) { - setRanges([[uniqueId(rangeStart), uniqueId(item)]]); - } - - explorer.resetSelectedItems(items); - return; - } - - const direction = getRangeDirection(range.end.index, rowIndex); - - if (!direction) return; - - const changeDirection = - !!range.direction && - range.direction !== direction && - (direction === 'down' - ? rowIndex > range.start.index - : rowIndex < range.start.index); - - let _ranges = ranges; - - 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; - - const row = rows[index]; - - if (row) explorer.removeSelectedItem(row.original); - }); - - _ranges = _ranges.filter((_, i) => i !== backRange.index); - } - - [ - ...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); - - const row = rows[index]; - - if (!row) return; - - const item = row.original; - - if (uniqueId(item) === uniqueId(range.start.original)) return; - - if ( - !range.direction || - direction === range.direction || - (changeDirection && - (range.direction === 'down' - ? index < range.start.index - : index > range.start.index)) - ) { - 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); - } - - setRanges([ - ..._ranges.slice(0, _ranges.length - 1), - [uniqueId(range.start.original), uniqueId(newRangeEnd)] - ]); - } else if (e.metaKey) { - const { rows } = table.getCoreRowModel(); - - if (explorer.selectedItems.has(item)) { - explorer.removeSelectedItem(item); - - const rowRanges = getRangesByRow(row); - - const range = rowRanges[0] || rowRanges[1]; - - if (range) { - const rangeStart = range.sorted.start.original; - const rangeEnd = range.sorted.end.original; - - if (rangeStart === rangeEnd) { - const closestRange = getClosestRange(range.index); - if (closestRange) { - const _ranges = ranges.filter( - (_, i) => i !== closestRange.index && i !== range.index - ); - - 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) { - 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 { - 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); - - setRanges([ - ..._ranges, - [ - uniqueId(item), - uniqueId( - closestRange.direction === 'down' - ? closestRange.sorted.end.original - : closestRange.sorted.start.original - ) - ] - ]); - } - } else { - setRanges([...ranges, itemRange]); - } - } - } else { - if (isSelected(item)) return; - - explorer.resetSelectedItems([item]); - const hash = uniqueId(item); - setRanges([[hash, hash]]); - } - } else { - explorer.resetSelectedItems([item]); - } - } - - function handleRowContextMenu(row: Row) { - if (explorerView.contextMenu === undefined) return; - - const item = row.original; - - if (!isSelected(item)) { - explorer.resetSelectedItems([item]); - const hash = uniqueId(item); - setRanges([[hash, hash]]); - } - } - - useEffect(() => { - if (locked && Object.keys(columnSizing).length > 0) { - table.setColumnSizing((sizing) => { - const nameSize = sizing.name; - const nameColumnMinSize = table.getColumn('name')?.columnDef.minSize; - const newNameSize = - (nameSize || 0) + tableWidth - paddingX * 2 - scrollBarWidth - tableLength; - - return { - ...sizing, - ...(nameSize !== undefined && nameColumnMinSize !== undefined - ? { - name: - newNameSize >= nameColumnMinSize - ? newNameSize - : nameColumnMinSize - } - : {}) - }; - }); - } else if (Math.abs(tableWidth - (tableLength + paddingX * 2 + scrollBarWidth)) < 15) { - setLocked(true); - } - // TODO: This should only depends on tableWidth, the lock logic should be behind a useEffectEvent (experimental) - // https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableWidth]); - - useEffect(() => setRanges([]), [explorer.items]); - - // Measure initial column widths - useEffect(() => { - if (!tableRef.current || sized) return; - - const columns = table.getAllColumns(); - const sizings = columns.reduce( - (sizings, column) => ({ ...sizings, [column.id]: column.getSize() }), - {} as ColumnSizingState - ); - const scrollWidth = tableRef.current.offsetWidth; - const sizingsSum = Object.values(sizings).reduce((a, b) => a + b, 0); - - if (sizingsSum < scrollWidth) { - const nameColSize = sizings.name; - const nameWidth = - scrollWidth - paddingX * 2 - scrollBarWidth - (sizingsSum - (nameColSize || 0)); - - table.setColumnSizing({ ...sizings, name: nameWidth }); - setLocked(true); - } else table.setColumnSizing(sizings); - - setSized(true); - }, [sized, table, paddingX]); - - // Load more items - useEffect(() => { - if (!explorer.loadMore) return; - - const lastRow = virtualRows[virtualRows.length - 1]; - if (!lastRow) return; - - const loadMoreFromRow = Math.ceil(rows.length * 0.75); - - if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined); - }, [virtualRows, rows.length, explorer.loadMore]); - - // Initialize column visibility from explorer settings - useEffect(() => { - !columnVisibility && setColumnVisibility(explorer.settingsStore.colVisibility); - }, [columnVisibility, explorer.settingsStore.colVisibility]); - - // Update column visibility in explorer settings - // We don't update directly because it takes too long to get the updated values - useEffect(() => { - if (!columnVisibility) return; - explorer.settingsStore.colVisibility = - columnVisibility as typeof explorer.settingsStore.colVisibility; - }, [columnVisibility, explorer]); - - useKey(['ArrowUp', 'ArrowDown', 'Escape'], (e) => { - if (!explorerView.selectable) return; - - e.preventDefault(); - - const range = getRangeByIndex(ranges.length - 1); - - if (e.key === 'ArrowDown' && explorer.selectedItems.size === 0) { - const item = rows[0]?.original; - if (item) { - explorer.addSelectedItem(item); - setRanges([[uniqueId(item), uniqueId(item)]]); - } - return; - } - - if (!range) return; - - if (e.key === 'Escape') { - explorer.resetSelectedItems([]); - setRanges([]); - return; - } - - const keyDirection = e.key === 'ArrowDown' ? 'down' : 'up'; - - const nextRow = rows[range.end.index + (keyDirection === 'up' ? -1 : 1)]; - - if (!nextRow) return; - - const item = nextRow.original; - - if (explorer.allowMultiSelect) { - if (e.shiftKey && !getQuickPreviewStore().open) { - const direction = range.direction || keyDirection; - - 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 (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 - ) - ]; - - 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); - } else { - setRanges([ - ...ranges.slice(0, ranges.length - 1), - [uniqueId(range.start.original), uniqueId(nextRow.original)] - ]); - } - } 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)] - ]); - } - } - } else { - explorer.resetSelectedItems([item]); - const hash = uniqueId(item); - setRanges([[hash, hash]]); - } - } else explorer.resetSelectedItems([item]); - - if (explorer.scrollRef.current) { - const tableBodyRect = tableBodyRef.current?.getBoundingClientRect(); - const scrollRect = explorer.scrollRef.current.getBoundingClientRect(); - - const paddingTop = parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop); - - const top = - (explorerView.top ? paddingTop + explorerView.top : paddingTop) + - scrollRect.top + - (isScrolled ? 35 : 0); - - const rowTop = - nextRow.index * rowHeight + - rowVirtualizer.options.paddingStart + - (tableBodyRect?.top || 0) + - scrollRect.top; - - const rowBottom = rowTop + rowHeight; - - if (rowTop < top) { - const scrollBy = rowTop - top - (nextRow.index === 0 ? paddingY : 0); - - explorer.scrollRef.current.scrollBy({ - top: scrollBy, - behavior: 'smooth' - }); - } else if (rowBottom > scrollRect.bottom) { - const scrollBy = - rowBottom - - scrollRect.height + - (nextRow.index === rows.length - 1 ? paddingY : 0); - - explorer.scrollRef.current.scrollBy({ - top: scrollBy, - behavior: 'smooth' - }); - } - } - }); - - useWindowEventListener('mouseup', () => { - if (resizing) { - setTimeout(() => { - //we need to update the store to trigger a DB update - explorer.settingsStore.colSizes = - columnSizing as typeof explorer.settingsStore.colSizes; - setResizing(false); - if (layout?.ref.current) { - layout.ref.current.style.cursor = ''; - } - }); - } - }); - - useMutationObserver(explorer.scrollRef, () => setListOffset(tableRef.current?.offsetTop ?? 0)); - - useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []); - - return ( -
- {sized && ( - - <> - -
- - {table.getHeaderGroups().map((headerGroup) => ( -
e.stopPropagation()} - > - {headerGroup.headers.map((header, i) => { - const size = header.column.getSize(); - - const orderingDirection = - settings.order && - orderingKey(settings.order) === - header.id - ? getOrderingDirection( - settings.order - ) - : null; - - const cellContent = flexRender( - header.column.columnDef.header, - header.getContext() - ); - - return ( -
{ - if (resizing) return; - - if ( - header.column.getCanSort() - ) { - if (orderingDirection) { - explorer.settingsStore.order = - createOrdering( - header.id, - orderingDirection === - 'Asc' - ? 'Desc' - : 'Asc' - ); - } else { - explorer.settingsStore.order = - createOrdering( - header.id, - 'Asc' - ); - } - } - }} - > - {header.isPlaceholder ? null : ( -
- {typeof cellContent === - 'string' && ( - - )} - - {orderingDirection === - 'Asc' && ( - - )} - {orderingDirection === - 'Desc' && ( - - )} - -
- e.stopPropagation() - } - onMouseDown={(e) => { - header.getResizeHandler()( - e - ); - setResizing(true); - setLocked(false); - - if ( - layout?.ref - .current - ) { - layout.ref.current.style.cursor = - 'col-resize'; - } - }} - onTouchStart={header.getResizeHandler()} - className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-app-line/50" - /> -
- )} -
- ); - })} -
- ))} -
- } - > - {table.getAllLeafColumns().map((column) => { - if (column.id === 'name') return null; - return ( - - ); - })} -
-
-
- - -
-
- {virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - const selected = isSelected(row.original); - - const previousRow = rows[virtualRow.index - 1]; - const selectedPrior = - previousRow && isSelected(previousRow.original); - - const nextRow = rows[virtualRow.index + 1]; - const selectedNext = - nextRow && isSelected(nextRow.original); - - const cut = isCut(row.original, explorerStore.cutCopyState); - - return ( -
-
{ - e.stopPropagation(); - handleRowClick(e, row); - }} - onContextMenu={() => handleRowContextMenu(row)} - className={clsx( - 'relative flex h-full w-full rounded-md border', - virtualRow.index % 2 === 0 && - 'bg-app-darkBox', - selected - ? 'border-accent !bg-accent/10' - : 'border-transparent', - selected && - selectedPrior && - 'rounded-t-none border-t-0 border-t-transparent', - selected && - selectedNext && - 'rounded-b-none border-b-0 border-b-transparent' - )} - > - {selectedPrior && ( -
- )} - - -
-
- ); - })} -
-
- - - - )} -
- ); -}; diff --git a/interface/app/$libraryId/Explorer/View/ListView/index.tsx b/interface/app/$libraryId/Explorer/View/ListView/index.tsx new file mode 100644 index 000000000..6da6b82fe --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView/index.tsx @@ -0,0 +1,993 @@ +import { CaretDown, CaretUp } from '@phosphor-icons/react'; +import { + flexRender, + VisibilityState, + type ColumnSizingState, + type Row +} from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import clsx from 'clsx'; +import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import BasicSticky from 'react-sticky-el'; +import { useKey, useMutationObserver, useWindowEventListener } from 'rooks'; +import useResizeObserver from 'use-resize-observer'; +import { type ExplorerItem } from '@sd/client'; +import { ContextMenu, Tooltip } from '@sd/ui'; +import { useIsTextTruncated } from '~/hooks'; +import { isNonEmptyObject } from '~/util'; + +import { useLayoutContext } from '../../../Layout/Context'; +import { useExplorerContext } from '../../Context'; +import { getQuickPreviewStore } from '../../QuickPreview/store'; +import { + createOrdering, + getOrderingDirection, + isCut, + orderingKey, + useExplorerStore +} from '../../store'; +import { uniqueId } from '../../util'; +import { useExplorerViewContext } from '../../ViewContext'; +import { ViewItem } from '../ViewItem'; +import { getRangeDirection, Range, useRanges } from './util/ranges'; +import { useTable } from './util/table'; + +interface ListViewItemProps { + row: Row; + paddingX: number; + // Props below are passed to trigger a rerender + // TODO: Find a better solution + columnSizing: ColumnSizingState; + columnVisibility: VisibilityState; + isCut: boolean; + isSelected: boolean; +} + +const ListViewItem = memo((props: ListViewItemProps) => { + return ( + + {props.row.getVisibleCells().map((cell) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} +
+ ); +}); + +const HeaderColumnName = ({ name }: { name: string }) => { + const textRef = useRef(null); + + const isTruncated = useIsTextTruncated(textRef, name); + + return ( +
+ {isTruncated ? ( + + {name} + + ) : ( + {name} + )} +
+ ); +}; + +const ROW_HEIGHT = 45; + +export default () => { + const layout = useLayoutContext(); + const explorer = useExplorerContext(); + const explorerStore = useExplorerStore(); + const explorerView = useExplorerViewContext(); + const settings = explorer.useSettingsSnapshot(); + + const tableRef = useRef(null); + const tableHeaderRef = useRef(null); + const tableBodyRef = useRef(null); + + const [sized, setSized] = useState(false); + const [locked, setLocked] = useState(false); + const [resizing, setResizing] = useState(false); + const [top, setTop] = useState(0); + const [listOffset, setListOffset] = useState(0); + const [ranges, setRanges] = useState([]); + const [isLeftMouseDown, setIsLeftMouseDown] = useState(false); + + const { table } = useTable(); + const { columnVisibility, columnSizing } = table.getState(); + const { rows, rowsById } = table.getRowModel(); + + const { getRangeByIndex, getRangesByRow, getClosestRange } = useRanges({ + ranges, + rows: rowsById + }); + + const padding = { + x: explorerView.padding?.x ?? 16, + y: explorerView.padding?.y ?? 12 + }; + + const rowVirtualizer = useVirtualizer({ + count: explorer.count ?? rows.length, + getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]), + estimateSize: useCallback(() => ROW_HEIGHT, []), + paddingStart: padding.y, + paddingEnd: padding.y, + scrollMargin: listOffset, + overscan: explorer.overscan ?? 10 + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + function handleRowClick( + e: React.MouseEvent, + row: Row + ) { + // Ensure mouse click is with left button + if (e.button !== 0) return; + + const rowIndex = row.index; + const item = row.original; + + if (explorer.allowMultiSelect) { + if (e.shiftKey) { + const range = getRangeByIndex(ranges.length - 1); + + if (!range) { + const items = [...Array(rowIndex + 1)].reduce((items, _, i) => { + const item = rows[i]?.original; + if (item) return [...items, item]; + return items; + }, []); + + const [rangeStart] = items; + + if (rangeStart) { + setRanges([[uniqueId(rangeStart), uniqueId(item)]]); + } + + explorer.resetSelectedItems(items); + return; + } + + const direction = getRangeDirection(range.end.index, rowIndex); + + if (!direction) return; + + const changeDirection = + !!range.direction && + range.direction !== direction && + (direction === 'down' + ? rowIndex > range.start.index + : rowIndex < range.start.index); + + let _ranges = ranges; + + 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; + + const row = rows[index]; + + if (row) explorer.removeSelectedItem(row.original); + }); + + _ranges = _ranges.filter((_, i) => i !== backRange.index); + } + + [ + ...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); + + const row = rows[index]; + + if (!row) return; + + const item = row.original; + + if (uniqueId(item) === uniqueId(range.start.original)) return; + + if ( + !range.direction || + direction === range.direction || + (changeDirection && + (range.direction === 'down' + ? index < range.start.index + : index > range.start.index)) + ) { + 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); + } + + 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 rowRanges = getRangesByRow(row); + + const range = rowRanges[0] || rowRanges[1]; + + if (range) { + const rangeStart = range.sorted.start.original; + const rangeEnd = range.sorted.end.original; + + if (rangeStart === rangeEnd) { + const closestRange = getClosestRange(range.index); + if (closestRange) { + const _ranges = ranges.filter( + (_, i) => i !== closestRange.index && i !== range.index + ); + + 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) { + 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 { + 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); + + 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]]); + } + } else { + explorer.resetSelectedItems([item]); + } + } + + function handleRowContextMenu(row: Row) { + if (explorerView.contextMenu === undefined) return; + + const item = row.original; + + if (!explorer.isItemSelected(item)) { + explorer.resetSelectedItems([item]); + const hash = uniqueId(item); + setRanges([[hash, hash]]); + } + } + + // Reset ranges + useEffect(() => setRanges([]), [explorer.items]); + + // Measure initial column widths + useEffect(() => { + if ( + !tableRef.current || + sized || + !isNonEmptyObject(columnSizing) || + !isNonEmptyObject(columnVisibility) + ) { + return; + } + + const sizing = table + .getVisibleLeafColumns() + .reduce( + (sizing, column) => ({ ...sizing, [column.id]: column.getSize() }), + {} as ColumnSizingState + ); + + const tableWidth = tableRef.current.offsetWidth; + const columnsWidth = Object.values(sizing).reduce((a, b) => a + b, 0) + padding.x * 2; + + if (columnsWidth < tableWidth) { + const nameWidth = (sizing.name ?? 0) + (tableWidth - columnsWidth); + table.setColumnSizing({ ...sizing, name: nameWidth }); + setLocked(true); + } else if (columnsWidth > tableWidth) { + const nameColSize = sizing.name ?? 0; + const minNameColSize = table.getColumn('name')?.columnDef.minSize; + + const difference = columnsWidth - tableWidth; + + if (minNameColSize !== undefined && nameColSize - difference >= minNameColSize) { + table.setColumnSizing({ ...sizing, name: nameColSize - difference }); + setLocked(true); + } + } else if (columnsWidth === tableWidth) { + setLocked(true); + } + + setSized(true); + }, [columnSizing, columnVisibility, padding.x, sized, table]); + + // Load more items + useEffect(() => { + if (!explorer.loadMore) return; + + const lastRow = virtualRows[virtualRows.length - 1]; + if (!lastRow) return; + + const loadMoreFromRow = Math.ceil(rows.length * 0.75); + + if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined); + }, [virtualRows, rows.length, explorer.loadMore]); + + // Sync scroll + useEffect(() => { + const table = tableRef.current; + const header = tableHeaderRef.current; + const body = tableBodyRef.current; + + if (!table || !header || !body) return; + + const handleWheel = (event: WheelEvent) => { + if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return; + event.preventDefault(); + header.scrollLeft += event.deltaX; + body.scrollLeft += event.deltaX; + }; + + const handleScroll = (element: HTMLDivElement) => { + if (isLeftMouseDown) return; + // Sorting sometimes resets scrollLeft + // so we reset it here in case it does + // to keep the scroll in sync + // TODO: Find a better solution + header.scrollLeft = element.scrollLeft; + body.scrollLeft = element.scrollLeft; + }; + + table.addEventListener('wheel', handleWheel); + header.addEventListener('scroll', () => handleScroll(header)); + body.addEventListener('scroll', () => handleScroll(body)); + + return () => { + table.removeEventListener('wheel', handleWheel); + header.addEventListener('scroll', () => handleScroll(header)); + body.addEventListener('scroll', () => handleScroll(body)); + }; + }, [sized, isLeftMouseDown]); + + // Handle key selection + useKey(['ArrowUp', 'ArrowDown', 'Escape'], (e) => { + if (!explorerView.selectable) return; + + e.preventDefault(); + + const range = getRangeByIndex(ranges.length - 1); + + if (e.key === 'ArrowDown' && explorer.selectedItems.size === 0) { + const item = rows[0]?.original; + if (item) { + explorer.addSelectedItem(item); + setRanges([[uniqueId(item), uniqueId(item)]]); + } + return; + } + + if (!range) return; + + if (e.key === 'Escape') { + explorer.resetSelectedItems([]); + setRanges([]); + return; + } + + const keyDirection = e.key === 'ArrowDown' ? 'down' : 'up'; + + const nextRow = rows[range.end.index + (keyDirection === 'up' ? -1 : 1)]; + + if (!nextRow) return; + + const item = nextRow.original; + + if (explorer.allowMultiSelect) { + if (e.shiftKey && !getQuickPreviewStore().open) { + const direction = range.direction || keyDirection; + + 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 (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 + ) + ]; + + 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); + } else { + setRanges([ + ...ranges.slice(0, ranges.length - 1), + [uniqueId(range.start.original), uniqueId(nextRow.original)] + ]); + } + } 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)] + ]); + } + } + } else { + explorer.resetSelectedItems([item]); + const hash = uniqueId(item); + setRanges([[hash, hash]]); + } + } else explorer.resetSelectedItems([item]); + + if (explorer.scrollRef.current) { + const tableBodyRect = tableBodyRef.current?.getBoundingClientRect(); + const scrollRect = explorer.scrollRef.current.getBoundingClientRect(); + + const paddingTop = parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop); + + const top = + (explorerView.top ? paddingTop + explorerView.top : paddingTop) + + scrollRect.top + + (explorer.scrollRef.current.scrollTop > listOffset ? 36 : 0); + + const rowTop = + nextRow.index * ROW_HEIGHT + + rowVirtualizer.options.paddingStart + + (tableBodyRect?.top || 0) + + scrollRect.top; + + const rowBottom = rowTop + ROW_HEIGHT; + + if (rowTop < top) { + const scrollBy = rowTop - top - (nextRow.index === 0 ? padding.y : 0); + + explorer.scrollRef.current.scrollBy({ + top: scrollBy, + behavior: 'smooth' + }); + } else if (rowBottom > scrollRect.bottom) { + const scrollBy = + rowBottom - + scrollRect.height + + (nextRow.index === rows.length - 1 ? padding.y : 0); + + explorer.scrollRef.current.scrollBy({ + top: scrollBy, + behavior: 'smooth' + }); + } + } + }); + + // Reset resizing cursor + useWindowEventListener('mouseup', () => { + // We timeout the reset so the col sorting + // doesn't get triggered on mouse up + setTimeout(() => { + setResizing(false); + setIsLeftMouseDown(false); + if (layout.ref.current) layout.ref.current.style.cursor = ''; + }); + }); + + // Handle table resize + useResizeObserver({ + ref: tableRef, + onResize: ({ width }) => { + if (!width) return; + + const sizing = table + .getVisibleLeafColumns() + .reduce( + (sizing, column) => ({ ...sizing, [column.id]: column.getSize() }), + {} as ColumnSizingState + ); + + const columnsWidth = Object.values(sizing).reduce((a, b) => a + b, 0) + padding.x * 2; + + if (locked) { + const newNameSize = (sizing.name ?? 0) + (width - columnsWidth); + const minNameColSize = table.getColumn('name')?.columnDef.minSize; + + if (minNameColSize !== undefined && newNameSize < minNameColSize) return; + + table.setColumnSizing({ + ...columnSizing, + name: newNameSize + }); + } else if (Math.abs(width - columnsWidth) < 15) { + setLocked(true); + } + } + }); + + // Set header position and list offset + useMutationObserver(explorer.scrollRef, () => { + const view = explorerView.ref.current; + const scroll = explorer.scrollRef.current; + if (!view || !scroll) return; + setTop( + explorerView.top ?? + parseInt(getComputedStyle(scroll).paddingTop) + scroll.getBoundingClientRect().top + ); + setListOffset(tableRef.current?.offsetTop ?? 0); + }); + + // Set list offset + useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []); + + return ( +
{ + e.stopPropagation(); + setIsLeftMouseDown(true); + }} + > + {sized && ( + <> + + + {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header, i) => { + const size = header.column.getSize(); + + const orderingDirection = + settings.order && + orderingKey(settings.order) === header.id + ? getOrderingDirection(settings.order) + : null; + + const cellContent = flexRender( + header.column.columnDef.header, + header.getContext() + ); + + return ( +
{ + if (resizing) return; + if (header.column.getCanSort()) { + explorer.settingsStore.order = + createOrdering( + header.id, + orderingDirection === 'Asc' + ? 'Desc' + : 'Asc' + ); + } + }} + > + {header.isPlaceholder ? null : ( + <> + {typeof cellContent === 'string' ? ( + + ) : ( + cellContent + )} + + {orderingDirection === 'Asc' && ( + + )} + + {orderingDirection === 'Desc' && ( + + )} + +
{ + setResizing(true); + setLocked(false); + + header.getResizeHandler()( + e + ); + + if (layout.ref.current) { + layout.ref.current.style.cursor = + 'col-resize'; + } + }} + onTouchStart={header.getResizeHandler()} + className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-sidebar-divider" + /> + + )} +
+ ); + })} +
+ ))} +
+ } + > + {table.getAllLeafColumns().map((column) => { + if (column.id === 'name') return null; + return ( + + ); + })} +
+
+ +
+
+ {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + const selected = explorer.isItemSelected(row.original); + const cut = isCut(row.original, explorerStore.cutCopyState); + + const previousRow = rows[virtualRow.index - 1]; + const nextRow = rows[virtualRow.index + 1]; + + const selectedPrior = + previousRow && explorer.isItemSelected(previousRow.original); + + const selectedNext = + nextRow && explorer.isItemSelected(nextRow.original); + + return ( +
handleRowClick(e, row)} + onContextMenu={() => handleRowContextMenu(row)} + > +
+ {selectedPrior && ( +
+ )} +
+ + +
+ ); + })} +
+
+ + )} +
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx b/interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx new file mode 100644 index 000000000..39615f17b --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx @@ -0,0 +1,158 @@ +import { Row } from '@tanstack/react-table'; +import { useCallback } from 'react'; +import { type ExplorerItem } from '@sd/client'; + +export function getRangeDirection(start: number, end: number) { + return start < end ? ('down' as const) : start > end ? ('up' as const) : null; +} + +export type Range = [string, string]; + +interface UseRangesProps { + ranges: Range[]; + rows: Record>; +} + +export const useRanges = ({ ranges, rows }: UseRangesProps) => { + const getRangeRows = useCallback( + (range: Range) => { + const rangeRows = range + .map((id) => rows[id]) + .filter((row): row is Row => Boolean(row)); + + const [start, end] = rangeRows; + + const [sortedStart, sortedEnd] = [...rangeRows].sort((a, b) => a.index - b.index); + + if (!start || !end || !sortedStart || !sortedEnd) return; + + return { start, end, sorted: { start: sortedStart, end: sortedEnd } }; + }, + [rows] + ); + + const getRangeByIndex = useCallback( + (index: number) => { + const range = ranges[index]; + + if (!range) return; + + const rangeRows = getRangeRows(range); + + if (!rangeRows) return; + + const direction = getRangeDirection(rangeRows.start.index, rangeRows.end.index); + + return { ...rangeRows, direction, index }; + }, + [getRangeRows, ranges] + ); + + const getRangesByRow = useCallback( + ({ index }: Row) => { + const _ranges = ranges.reduce>[]>( + (ranges, range, i) => { + const rangeRows = getRangeRows(range); + + if (!rangeRows) return ranges; + + if ( + index >= rangeRows.sorted.start.index && + index <= rangeRows.sorted.end.index + ) { + const range = getRangeByIndex(i); + return range ? [...ranges, range] : ranges; + } + + return ranges; + }, + [] + ); + + return _ranges; + }, + [getRangeByIndex, getRangeRows, ranges] + ); + + const sortRanges = useCallback( + (ranges: Range[]) => { + return ranges + .map((range, i) => { + const rows = getRangeRows(range); + + if (!rows) return; + + return { + index: i, + ...rows + }; + }) + .filter( + ( + range + ): range is NonNullable> & { index: number } => + Boolean(range) + ) + .sort((a, b) => a.sorted.start.index - b.sorted.start.index); + }, + [getRangeRows] + ); + + const getClosestRange = useCallback( + ( + rangeIndex: number, + options: { + direction?: 'up' | 'down'; + maxRowDifference?: number; + ranges?: Range[]; + } = {} + ) => { + const range = getRangeByIndex(rangeIndex); + + let _ranges = sortRanges(options.ranges || ranges); + + if (range) { + _ranges = _ranges.filter( + (_range) => + range.index === _range.index || + range.sorted.start.index < _range.sorted.start.index || + range.sorted.end.index > _range.sorted.end.index + ); + } + + const targetRangeIndex = _ranges.findIndex(({ index }) => rangeIndex === index); + + const targetRange = _ranges[targetRangeIndex]; + + if (!targetRange) return; + + const closestRange = + options.direction === 'down' + ? _ranges[targetRangeIndex + 1] + : options.direction === 'up' + ? _ranges[targetRangeIndex - 1] + : _ranges[targetRangeIndex + 1] || _ranges[targetRangeIndex - 1]; + + if (!closestRange) return; + + const direction = options.direction || (_ranges[targetRangeIndex + 1] ? 'down' : 'up'); + + const rowDifference = + direction === 'down' + ? closestRange.sorted.start.index - 1 - targetRange.sorted.end.index + : targetRange.sorted.start.index - (closestRange.sorted.end.index + 1); + + if (options.maxRowDifference !== undefined && rowDifference > options.maxRowDifference) + return; + + return { + ...closestRange, + direction, + rowDifference + }; + }, + [getRangeByIndex, ranges, sortRanges] + ); + + return { getRangeByIndex, getRangesByRow, getClosestRange }; +}; diff --git a/interface/app/$libraryId/Explorer/View/ListView/util/table.tsx b/interface/app/$libraryId/Explorer/View/ListView/util/table.tsx new file mode 100644 index 000000000..f82639c1e --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView/util/table.tsx @@ -0,0 +1,184 @@ +import { + getCoreRowModel, + useReactTable, + type ColumnDef, + type ColumnSizingState, + type VisibilityState +} from '@tanstack/react-table'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { useEffect, useMemo, useState } from 'react'; +import { stringify } from 'uuid'; +import { + byteSize, + getExplorerItemData, + getItemFilePath, + getItemObject, + type ExplorerItem +} from '@sd/client'; +import { isNonEmptyObject } from '~/util'; + +import { useExplorerContext } from '../../../Context'; +import { FileThumb } from '../../../FilePath/Thumb'; +import { InfoPill } from '../../../Inspector'; +import { useQuickPreviewStore } from '../../../QuickPreview/store'; +import { isCut, useExplorerStore } from '../../../store'; +import { uniqueId } from '../../../util'; +import RenamableItemText from '../../RenamableItemText'; + +export const useTable = () => { + const explorer = useExplorerContext(); + const explorerStore = useExplorerStore(); + const quickPreviewStore = useQuickPreviewStore(); + + const [columnSizing, setColumnSizing] = useState({}); + const [columnVisibility, setColumnVisibility] = useState({}); + + const columns = useMemo[]>( + () => [ + { + id: 'name', + header: 'Name', + minSize: 200, + maxSize: undefined, + accessorFn: (file) => getExplorerItemData(file).fullName, + cell: (cell) => { + const item = cell.row.original; + + const selected = explorer.selectedItems.has(item); + const cut = isCut(item, explorerStore.cutCopyState); + + return ( +
+ + + 1 || + quickPreviewStore.open + } + style={{ maxHeight: 36 }} + /> +
+ ); + } + }, + { + id: 'kind', + header: 'Type', + enableSorting: false, + accessorFn: (file) => getExplorerItemData(file).kind, + cell: (cell) => ( + + {getExplorerItemData(cell.row.original).kind} + + ) + }, + { + id: 'sizeInBytes', + header: 'Size', + accessorFn: (file) => { + const file_path = getItemFilePath(file); + if (!file_path || !file_path.size_in_bytes_bytes) return; + + return byteSize(file_path.size_in_bytes_bytes); + } + }, + { + id: 'dateCreated', + header: 'Date Created', + accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY') + }, + { + id: 'dateModified', + header: 'Date Modified', + accessorFn: (file) => + dayjs(getItemFilePath(file)?.date_modified).format('MMM Do YYYY') + }, + { + id: 'dateIndexed', + header: 'Date Indexed', + accessorFn: (file) => { + const item = getItemFilePath(file); + return dayjs( + (item && 'date_indexed' in item && item.date_indexed) || null + ).format('MMM Do YYYY'); + } + }, + { + id: 'dateAccessed', + header: 'Date Accessed', + accessorFn: (file) => + getItemObject(file)?.date_accessed && + dayjs(getItemObject(file)?.date_accessed).format('MMM Do YYYY') + }, + { + id: 'contentId', + header: 'Content ID', + enableSorting: false, + accessorFn: (file) => getExplorerItemData(file).casId + }, + { + id: 'objectId', + header: 'Object ID', + enableSorting: false, + accessorFn: (file) => { + const value = getItemObject(file)?.pub_id; + if (!value) return null; + return stringify(value); + } + } + ], + [explorer.selectedItems, explorerStore.cutCopyState, quickPreviewStore.open] + ); + + const table = useReactTable({ + data: useMemo(() => explorer.items ?? [], [explorer.items]), + columns, + defaultColumn: { minSize: 100, maxSize: 250 }, + state: { columnSizing, columnVisibility }, + onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, + columnResizeMode: 'onChange', + getCoreRowModel: useMemo(() => getCoreRowModel(), []), + getRowId: uniqueId + }); + + // Initialize column visibility from explorer settings + useEffect(() => { + if (isNonEmptyObject(columnVisibility)) return; + table.setColumnVisibility(explorer.settingsStore.colVisibility); + }, [columnVisibility, explorer.settingsStore.colVisibility, table]); + + // Update column visibility in explorer settings + // We don't update directly because it takes too long to get the updated values + useEffect(() => { + if (!isNonEmptyObject(columnVisibility)) return; + explorer.settingsStore.colVisibility = + columnVisibility as typeof explorer.settingsStore.colVisibility; + }, [columnVisibility, explorer]); + + // Initialize column sizes from explorer settings + useEffect(() => { + if (isNonEmptyObject(columnSizing)) return; + table.setColumnSizing(explorer.settingsStore.colSizes); + }, [columnSizing, explorer.settingsStore.colSizes, table]); + + // Update column sizing in explorer settings + // We don't update directly because it takes too long to get the updated values + useEffect(() => { + if (!isNonEmptyObject(columnSizing)) return; + explorer.settingsStore.colSizes = columnSizing as typeof explorer.settingsStore.colSizes; + }, [columnSizing, explorer]); + + return { table }; +}; diff --git a/interface/app/$libraryId/Explorer/View/MediaView.tsx b/interface/app/$libraryId/Explorer/View/MediaView.tsx index bafaa0ce0..03e2c2159 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView.tsx @@ -4,11 +4,11 @@ import { memo } from 'react'; import { ExplorerItem } from '@sd/client'; import { Button } from '@sd/ui'; -import { ViewItem } from '.'; import { useExplorerContext } from '../Context'; import { FileThumb } from '../FilePath/Thumb'; import { getQuickPreviewStore } from '../QuickPreview/store'; import GridList from './GridList'; +import { ViewItem } from './ViewItem'; interface MediaViewItemProps { data: ExplorerItem; diff --git a/interface/app/$libraryId/Explorer/View/ViewItem.tsx b/interface/app/$libraryId/Explorer/View/ViewItem.tsx new file mode 100644 index 000000000..3d615c120 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ViewItem.tsx @@ -0,0 +1,175 @@ +import { memo, useCallback, type HTMLAttributes, type PropsWithChildren } from 'react'; +import { createSearchParams, useNavigate } from 'react-router-dom'; +import { + isPath, + useLibraryContext, + useLibraryMutation, + type ExplorerItem, + type FilePath, + type Location, + type NonIndexedPathItem +} from '@sd/client'; +import { ContextMenu, toast } from '@sd/ui'; +import { isNonEmpty } from '~/util'; +import { usePlatform } from '~/util/Platform'; + +import { useExplorerContext } from '../Context'; +import { getQuickPreviewStore } from '../QuickPreview/store'; +import { uniqueId } from '../util'; +import { useExplorerViewContext } from '../ViewContext'; + +export const useViewItemDoubleClick = () => { + const navigate = useNavigate(); + const explorer = useExplorerContext(); + const { library } = useLibraryContext(); + const { openFilePaths } = usePlatform(); + + const updateAccessTime = useLibraryMutation('files.updateAccessTime'); + + const doubleClick = useCallback( + async (item?: ExplorerItem) => { + const selectedItems = [...explorer.selectedItems]; + + if (!isNonEmpty(selectedItems)) return; + + let itemIndex = 0; + const items = selectedItems.reduce( + (items, selectedItem, i) => { + const sameAsClicked = item && uniqueId(item) === uniqueId(selectedItem); + + if (sameAsClicked) itemIndex = i; + + switch (selectedItem.type) { + case 'Location': { + items.locations.splice(sameAsClicked ? 0 : -1, 0, selectedItem.item); + break; + } + case 'NonIndexedPath': { + items.non_indexed.splice(sameAsClicked ? 0 : -1, 0, selectedItem.item); + break; + } + default: { + for (const filePath of selectedItem.type === 'Path' + ? [selectedItem.item] + : selectedItem.item.file_paths) { + if (isPath(selectedItem) && selectedItem.item.is_dir) { + items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath); + } else { + items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath); + } + } + break; + } + } + + return items; + }, + { + dirs: [], + paths: [], + locations: [], + non_indexed: [] + } as { + dirs: FilePath[]; + paths: FilePath[]; + locations: Location[]; + non_indexed: NonIndexedPathItem[]; + } + ); + + if (items.paths.length > 0) { + if (explorer.settingsStore.openOnDoubleClick === 'openFile' && openFilePaths) { + updateAccessTime + .mutateAsync(items.paths.map(({ object_id }) => object_id!).filter(Boolean)) + .catch(console.error); + + try { + await openFilePaths( + library.uuid, + items.paths.map(({ id }) => id) + ); + } catch (error) { + toast.error({ title: 'Failed to open file', body: `Error: ${error}.` }); + } + } else if (item && explorer.settingsStore.openOnDoubleClick === 'quickPreview') { + if (item.type !== 'Location' && !(isPath(item) && item.item.is_dir)) { + getQuickPreviewStore().itemIndex = itemIndex; + getQuickPreviewStore().open = true; + return; + } + } + } + + if (items.dirs.length > 0) { + const [item] = items.dirs; + if (item) { + navigate({ + pathname: `../location/${item.location_id}`, + search: createSearchParams({ + path: `${item.materialized_path}${item.name}/` + }).toString() + }); + return; + } + } + + if (items.locations.length > 0) { + const [location] = items.locations; + if (location) { + navigate({ + pathname: `../location/${location.id}`, + search: createSearchParams({ + path: `/` + }).toString() + }); + return; + } + } + + if (items.non_indexed.length > 0) { + const [non_indexed] = items.non_indexed; + if (non_indexed) { + navigate({ + search: createSearchParams({ path: non_indexed.path }).toString() + }); + return; + } + } + }, + [ + explorer.selectedItems, + explorer.settingsStore.openOnDoubleClick, + library.uuid, + navigate, + openFilePaths, + updateAccessTime + ] + ); + + return { doubleClick }; +}; + +interface ViewItemProps extends PropsWithChildren, HTMLAttributes { + data: ExplorerItem; +} + +export const ViewItem = memo(({ data, children, ...props }: ViewItemProps) => { + const explorerView = useExplorerViewContext(); + + const { doubleClick } = useViewItemDoubleClick(); + + return ( + doubleClick(data)} {...props}> + {children} +
+ } + onOpenChange={explorerView.setIsContextMenuOpen} + disabled={explorerView.contextMenu === undefined} + onMouseDown={(e) => e.stopPropagation()} + > + {explorerView.contextMenu} + + ); +}); diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 8db6cd15b..c8ef531b3 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -42,232 +42,101 @@ import { useExplorerViewContext, ViewContext, type ExplorerViewContext } from '. import GridView from './GridView'; import ListView from './ListView'; import MediaView from './MediaView'; - -interface ViewItemProps extends PropsWithChildren, HTMLAttributes { - data: ExplorerItem; -} - -export const ViewItem = ({ data, children, ...props }: ViewItemProps) => { - const explorer = useExplorerContext(); - const explorerView = useExplorerViewContext(); - - const navigate = useNavigate(); - const { library } = useLibraryContext(); - const { openFilePaths } = usePlatform(); - - const updateAccessTime = useLibraryMutation('files.updateAccessTime'); - const metaCtrlKey = useKeyMatcher('Meta').key; - - useKeys([metaCtrlKey, 'ArrowUp'], async (e) => { - e.stopPropagation(); - await onDoubleClick(); - }); - - const onDoubleClick = async () => { - const selectedItems = [...explorer.selectedItems]; - - if (!isNonEmpty(selectedItems)) return; - - let itemIndex = 0; - const items = selectedItems.reduce( - (items, item, i) => { - const sameAsClicked = uniqueId(data) === uniqueId(item); - - if (sameAsClicked) itemIndex = i; - - switch (item.type) { - case 'Location': { - items.locations.splice(sameAsClicked ? 0 : -1, 0, item.item); - break; - } - case 'NonIndexedPath': { - items.non_indexed.splice(sameAsClicked ? 0 : -1, 0, item.item); - break; - } - default: { - for (const filePath of item.type === 'Path' - ? [item.item] - : item.item.file_paths) { - if (isPath(item) && item.item.is_dir) { - items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath); - } else { - items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath); - } - } - break; - } - } - - return items; - }, - { - dirs: [], - paths: [], - locations: [], - non_indexed: [] - } as { - dirs: FilePath[]; - paths: FilePath[]; - locations: Location[]; - non_indexed: NonIndexedPathItem[]; - } - ); - - if (items.paths.length > 0 && !explorerView.isRenaming) { - if (explorer.settingsStore.openOnDoubleClick === 'openFile' && openFilePaths) { - updateAccessTime - .mutateAsync(items.paths.map(({ object_id }) => object_id!).filter(Boolean)) - .catch(console.error); - - try { - await openFilePaths( - library.uuid, - items.paths.map(({ id }) => id) - ); - } catch (error) { - toast.error({ title: 'Failed to open file', body: `Error: ${error}.` }); - } - } else if (explorer.settingsStore.openOnDoubleClick === 'quickPreview') { - if (data.type !== 'Location' && !(isPath(data) && data.item.is_dir)) { - getQuickPreviewStore().itemIndex = itemIndex; - getQuickPreviewStore().open = true; - return; - } - } - } - - if (items.dirs.length > 0) { - const [item] = items.dirs; - if (item) { - navigate({ - pathname: `../location/${item.location_id}`, - search: createSearchParams({ - path: `${item.materialized_path}${item.name}/` - }).toString() - }); - return; - } - } - - if (items.locations.length > 0) { - const [location] = items.locations; - if (location) { - navigate({ - pathname: `../location/${location.id}`, - search: createSearchParams({ - path: `/` - }).toString() - }); - return; - } - } - - if (items.non_indexed.length > 0) { - const [non_indexed] = items.non_indexed; - if (non_indexed) { - navigate({ - search: createSearchParams({ path: non_indexed.path }).toString() - }); - return; - } - } - }; - - return ( - - {children} -
- } - onOpenChange={explorerView.setIsContextMenuOpen} - disabled={explorerView.contextMenu === undefined} - asChild={false} - onMouseDown={(e) => e.stopPropagation()} - > - {explorerView.contextMenu} - - ); -}; +import { useViewItemDoubleClick } from './ViewItem'; export interface ExplorerViewProps extends Omit< ExplorerViewContext, - 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen' | 'ref' + 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen' | 'ref' | 'padding' > { className?: string; style?: React.CSSProperties; emptyNotice?: JSX.Element; + padding?: number | { x?: number; y?: number }; } -export default memo(({ className, style, emptyNotice, ...contextProps }: ExplorerViewProps) => { - const explorer = useExplorerContext(); - const quickPreviewStore = useQuickPreviewStore(); +export default memo( + ({ className, style, emptyNotice, padding, ...contextProps }: ExplorerViewProps) => { + const explorer = useExplorerContext(); + const quickPreview = useQuickPreviewContext(); + const quickPreviewStore = useQuickPreviewStore(); - const quickPreview = useQuickPreviewContext(); + const { doubleClick } = useViewItemDoubleClick(); - const { layoutMode } = explorer.useSettingsSnapshot(); + const { layoutMode } = explorer.useSettingsSnapshot(); - const ref = useRef(null); + const metaCtrlKey = useKeyMatcher('Meta').key; - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); - const [showLoading, setShowLoading] = useState(false); + const ref = useRef(null); - useKeyDownHandlers({ - disabled: isRenaming || quickPreviewStore.open - }); + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [showLoading, setShowLoading] = useState(false); - useEffect(() => { - if (explorer.isFetchingNextPage) { - const timer = setTimeout(() => setShowLoading(true), 100); - return () => clearTimeout(timer); - } else setShowLoading(false); - }, [explorer.isFetchingNextPage]); + useKeyDownHandlers({ + disabled: isRenaming || quickPreviewStore.open + }); - return ( - <> -
{ - if (e.button === 2 || (e.button === 0 && e.shiftKey)) return; + useEffect(() => { + if (explorer.isFetchingNextPage) { + const timer = setTimeout(() => setShowLoading(true), 100); + return () => clearTimeout(timer); + } else setShowLoading(false); + }, [explorer.isFetchingNextPage]); - explorer.resetSelectedItems(); - }} - > - {explorer.items === null || (explorer.items && explorer.items.length > 0) ? ( - - {layoutMode === 'grid' && } - {layoutMode === 'list' && } - {layoutMode === 'media' && } - {showLoading && ( - - )} - - ) : ( - emptyNotice - )} -
+ useKeys([metaCtrlKey, 'ArrowUp'], (e) => { + e.stopPropagation(); + doubleClick(); + }); - {quickPreview.ref && createPortal(, quickPreview.ref)} - - ); -}); + return ( + <> +
{ + if (e.button === 2 || (e.button === 0 && e.shiftKey)) return; + + explorer.resetSelectedItems(); + }} + > + {explorer.items === null || (explorer.items && explorer.items.length > 0) ? ( + + {layoutMode === 'grid' && } + {layoutMode === 'list' && } + {layoutMode === 'media' && } + {showLoading && ( + + )} + + ) : ( + emptyNotice + )} +
+ + {quickPreview.ref && createPortal(, quickPreview.ref)} + + ); + } +); export const EmptyNotice = (props: { icon?: Icon | ReactNode; message?: ReactNode }) => { const { layoutMode } = useExplorerContext().useSettingsSnapshot(); diff --git a/interface/app/$libraryId/Explorer/ViewContext.ts b/interface/app/$libraryId/Explorer/ViewContext.ts index 300b6c125..6318147b5 100644 --- a/interface/app/$libraryId/Explorer/ViewContext.ts +++ b/interface/app/$libraryId/Explorer/ViewContext.ts @@ -7,9 +7,12 @@ export interface ExplorerViewContext { setIsContextMenuOpen?: (isOpen: boolean) => void; isRenaming: boolean; setIsRenaming: (isRenaming: boolean) => void; - padding?: number | { x?: number; y?: number }; + padding?: { x?: number; y?: number }; gap?: number | { x?: number; y?: number }; selectable: boolean; + listViewOptions?: { + hideHeaderBorder?: boolean; + }; } export const ViewContext = createContext(null); diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 7b16b88a3..5855c86b6 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -62,6 +62,7 @@ export default function Explorer(props: PropsWithChildren) { /> ) } + listViewOptions={{ hideHeaderBorder: true }} /> diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 680656de7..0397a1edb 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -170,6 +170,10 @@ function useSelectedItems(items: ExplorerItem[] | null) { updateHashes(); }, [selectedItemHashes.value, updateHashes] + ), + isItemSelected: useCallback( + (item: ExplorerItem) => selectedItems.has(item), + [selectedItems] ) }; } diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 567088e91..7616cb852 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -46,7 +46,7 @@ export const Component = () => {
diff --git a/interface/package.json b/interface/package.json index 164c3aca1..84e617937 100644 --- a/interface/package.json +++ b/interface/package.json @@ -34,9 +34,8 @@ "@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^4.12.0", "@tanstack/react-query-devtools": "^4.22.0", - "@tanstack/react-table": "^8.8.5", + "@tanstack/react-table": "^8.10.0", "@tanstack/react-virtual": "3.0.0-beta.54", - "@types/react-scroll-sync": "^0.8.4", "@types/uuid": "^9.0.2", "@vitejs/plugin-react": "^2.1.0", "autoprefixer": "^10.4.12", @@ -61,7 +60,6 @@ "react-qr-code": "^2.0.11", "react-router": "6.9.0", "react-router-dom": "6.9.0", - "react-scroll-sync": "^0.11.0", "react-selecto": "^1.26.0", "react-sticky-el": "^2.1.0", "react-use-draggable-scroll": "^0.4.7", diff --git a/interface/util/index.tsx b/interface/util/index.tsx index e9b10a0c1..34fd7b43e 100644 --- a/interface/util/index.tsx +++ b/interface/util/index.tsx @@ -7,3 +7,4 @@ export const generatePassword = (length: number) => export type NonEmptyArray = [T, ...T[]]; export const isNonEmpty = (input: T[]): input is NonEmptyArray => input.length > 0; +export const isNonEmptyObject = (input: object) => Object.keys(input).length > 0; diff --git a/packages/client/src/utils/explorerItem.ts b/packages/client/src/utils/explorerItem.ts index 87b5d40a2..15416e918 100644 --- a/packages/client/src/utils/explorerItem.ts +++ b/packages/client/src/utils/explorerItem.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import type { ExplorerItem, FilePath, Object } from '../core'; +import type { ExplorerItem, FilePath, NonIndexedPathItem, Object } from '../core'; import { byteSize } from '../lib'; import { ObjectKind } from './objectKind'; @@ -32,6 +32,7 @@ export function getExplorerItemData(data?: null | ExplorerItem) { const itemData = { name: null as string | null, + fullName: null as string | null, size: byteSize(0), kind, isDir: false, @@ -52,6 +53,9 @@ export function getExplorerItemData(data?: null | ExplorerItem) { const location = getItemLocation(data); if (filePath) { itemData.name = filePath.name; + itemData.fullName = `${filePath.name}${ + filePath.extension ? `.${filePath.extension}` : '' + }}`; itemData.size = byteSize(filePath.size_in_bytes_bytes); itemData.isDir = filePath.is_dir ?? false; itemData.extension = filePath.extension; @@ -65,6 +69,7 @@ export function getExplorerItemData(data?: null | ExplorerItem) { itemData.size = byteSize(location.total_capacity - location.available_capacity); itemData.name = location.name; + itemData.fullName = location.name; itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown'; itemData.isDir = true; itemData.locationId = location.id; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5bef41c2..9352ccd85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -671,14 +671,11 @@ importers: specifier: ^4.22.0 version: 4.22.0(@tanstack/react-query@4.29.1)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-table': - specifier: ^8.8.5 - version: 8.8.5(react-dom@18.2.0)(react@18.2.0) + specifier: ^8.10.0 + version: 8.10.0(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-virtual': specifier: 3.0.0-beta.54 version: 3.0.0-beta.54(react@18.2.0) - '@types/react-scroll-sync': - specifier: ^0.8.4 - version: 0.8.4 '@types/uuid': specifier: ^9.0.2 version: 9.0.2 @@ -748,9 +745,6 @@ importers: react-router-dom: specifier: 6.9.0 version: 6.9.0(react-dom@18.2.0)(react@18.2.0) - react-scroll-sync: - specifier: ^0.11.0 - version: 0.11.0(react-dom@18.2.0)(react@18.2.0) react-selecto: specifier: ^1.26.0 version: 1.26.0 @@ -10081,14 +10075,14 @@ packages: react-native: 0.72.4(@babel/core@7.22.11)(@babel/preset-env@7.22.10)(react@18.2.0) use-sync-external-store: 1.2.0(react@18.2.0) - /@tanstack/react-table@8.8.5(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-g/t21E/ICHvaCOJOhsDNr5QaB/6aDQEHFbx/YliwwU/CJThMqG+dS28vnToIBV/5MBgpeXoGRi2waDJVJlZrtg==} + /@tanstack/react-table@8.10.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-FNhKE3525hryvuWw90xRbP16qNiq7OLJkDZopOKcwyktErLi1ibJzAN9DFwA/gR1br9SK4StXZh9JPvp9izrrQ==} engines: {node: '>=12'} peerDependencies: react: '>=16' react-dom: '>=16' dependencies: - '@tanstack/table-core': 8.8.5 + '@tanstack/table-core': 8.10.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -10102,8 +10096,8 @@ packages: react: 18.2.0 dev: false - /@tanstack/table-core@8.8.5: - resolution: {integrity: sha512-Xnwa1qxpgvSW7ozLiexmKp2PIYcLBiY/IizbdGriYCL6OOHuZ9baRhrrH51zjyz+61ly6K59rmt6AI/3RR+97Q==} + /@tanstack/table-core@8.10.0: + resolution: {integrity: sha512-e701yAJ18aGDP6mzVworlFAmQ+gi3Wtqx5mGZUe2BUv4W4D80dJxUfkHdtEGJ6GryAnlCCNTej7eaJiYmPhyYg==} engines: {node: '>=12'} dev: false @@ -10578,12 +10572,6 @@ packages: '@types/react': 18.0.38 dev: true - /@types/react-scroll-sync@0.8.4: - resolution: {integrity: sha512-88N2vgZdVqlwr5ayH/5GNAAjfdlzhted/qPTyXgT/DzQwsuIWkwFpZtoOhyGCRmxUC3w5wA+ZhkpbzagIXWNaQ==} - dependencies: - '@types/react': 18.0.38 - dev: false - /@types/react@18.0.38: resolution: {integrity: sha512-ExsidLLSzYj4cvaQjGnQCk4HFfVT9+EZ9XZsQ8Hsrcn8QNgXtpZ3m9vSIC2MWtx7jHictK6wYhQgGh6ic58oOw==} dependencies: @@ -20417,17 +20405,6 @@ packages: react: 18.2.0 dev: false - /react-scroll-sync@0.11.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-COfA885/2NAQPxusIU6sukKiSFXgAd9+OWD7iBWA4NF5Y1Np8poBKLwgZ3y4iywo5/+ch0PLGz887myIbm+fPw==} - peerDependencies: - react: 16.x || 17.x || 18.x - react-dom: 16.x || 17.x || 18.x - dependencies: - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /react-selecto@1.26.0: resolution: {integrity: sha512-aBTZEYA68uE+o8TytNjTb2GpIn4oKEv0U4LIow3cspJQlF/PdAnBwkq9UuiKVuFluu5kfLQ7Keu3S2Tihlmw0g==} dependencies: