From ff9515bdb4511691aaa8aa90efcc1d6883de8a7f Mon Sep 17 00:00:00 2001 From: nikec <43032218+niikeec@users.noreply.github.com> Date: Tue, 6 Jun 2023 08:55:56 +0200 Subject: [PATCH] [ENG 655] Explorer restructure (#858) * wip * wip 2 * Grid list single selection * core & pnpm-lock * Merge branch 'main' Conflicts: interface/app/$libraryId/Explorer/index.tsx * missing import from merge * fix total_orphan_paths bug * add top bar context * missing pieces of merge * missing pieces of merge * missing divs * Fill fallback value - was causing null error of page * spelling fixes * notice light theme, list view update, other explorer updates * Update pnpm-lock * Remove procedure * fix light menu ink color * fix list view scrolled state * Change layout default * Remove unused imports * remove keys * empty notice & context menu overview * Fix prevent selection while context menu is up * Fix scroll with keys * Empty notice icon * Add light icons * Context menu and fixed list view scroll * Fix name column sizing * top/bottom scroll position * Tag assign only when objectData * Fix list view locked state * fix ci * shamefully ignore eslint --------- Co-authored-by: Jamie Pine Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com> Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com> --- apps/landing/src/env.ts | 2 +- interface/ErrorFallback.tsx | 2 +- .../$libraryId/Explorer/DismissibleNotice.tsx | 49 +- .../$libraryId/Explorer/File/ContextMenu.tsx | 416 +++++----- .../Explorer/File/RenameTextBox.tsx | 34 +- .../app/$libraryId/Explorer/File/Thumb.tsx | 36 +- .../app/$libraryId/Explorer/GridView.tsx | 224 ------ .../$libraryId/Explorer/Inspector/index.tsx | 28 +- .../app/$libraryId/Explorer/ListView.tsx | 444 ----------- .../app/$libraryId/Explorer/MediaView.tsx | 224 ------ .../app/$libraryId/Explorer/OptionsPanel.tsx | 3 +- .../app/$libraryId/Explorer/QuickPreview.tsx | 2 +- interface/app/$libraryId/Explorer/View.tsx | 135 ---- .../app/$libraryId/Explorer/View/GridView.tsx | 110 +++ .../app/$libraryId/Explorer/View/ListView.tsx | 724 ++++++++++++++++++ .../$libraryId/Explorer/View/MediaView.tsx | 97 +++ .../app/$libraryId/Explorer/View/index.tsx | 161 ++++ .../app/$libraryId/Explorer/ViewContext.ts | 24 +- interface/app/$libraryId/Explorer/index.tsx | 124 +-- .../Sidebar/JobManager/useGroupedJobs.ts | 16 +- .../Layout/Sidebar/LibrariesDropdown.tsx | 2 +- .../Layout/Sidebar/LibrarySection.tsx | 16 +- .../app/$libraryId/TopBar/TopBarOptions.tsx | 2 +- interface/app/$libraryId/location/$id.tsx | 47 +- .../$libraryId/location/LocationOptions.tsx | 59 ++ interface/app/$libraryId/overview/data.ts | 12 +- interface/app/$libraryId/overview/index.tsx | 66 +- .../$libraryId/settings/client/appearance.tsx | 8 +- .../locations/IndexerRuleEditor/RulesForm.tsx | 2 +- interface/app/style.scss | 7 + interface/components/GridList.tsx | 430 +++++++++++ interface/hooks/useDragSelect.tsx | 39 + interface/hooks/useExplorerStore.tsx | 1 + interface/package.json | 7 +- packages/ui/src/ContextMenu.tsx | 17 +- packages/ui/src/Dialog.tsx | 2 +- packages/ui/src/Popover.tsx | 5 + packages/ui/style/colors.scss | 2 +- pnpm-lock.yaml | Bin 909312 -> 914301 bytes 39 files changed, 2159 insertions(+), 1420 deletions(-) delete mode 100644 interface/app/$libraryId/Explorer/GridView.tsx delete mode 100644 interface/app/$libraryId/Explorer/ListView.tsx delete mode 100644 interface/app/$libraryId/Explorer/MediaView.tsx delete mode 100644 interface/app/$libraryId/Explorer/View.tsx create mode 100644 interface/app/$libraryId/Explorer/View/GridView.tsx create mode 100644 interface/app/$libraryId/Explorer/View/ListView.tsx create mode 100644 interface/app/$libraryId/Explorer/View/MediaView.tsx create mode 100644 interface/app/$libraryId/Explorer/View/index.tsx create mode 100644 interface/app/$libraryId/location/LocationOptions.tsx create mode 100644 interface/components/GridList.tsx create mode 100644 interface/hooks/useDragSelect.tsx diff --git a/apps/landing/src/env.ts b/apps/landing/src/env.ts index 305b7adce..fc00ef86c 100644 --- a/apps/landing/src/env.ts +++ b/apps/landing/src/env.ts @@ -28,4 +28,4 @@ export const env = createEnv({ // In dev or in eslint disable checking. // Kinda sucks for in dev but you don't need the whole setup to change the docs. skipValidation: process.env.VERCEL !== '1' -}); +}); \ No newline at end of file diff --git a/interface/ErrorFallback.tsx b/interface/ErrorFallback.tsx index f16f5ed59..8808739ba 100644 --- a/interface/ErrorFallback.tsx +++ b/interface/ErrorFallback.tsx @@ -86,7 +86,7 @@ export function ErrorPage({

- - - ); -}); - -export default () => { - const explorerStore = useExplorerStore(); - const dismissibleNoticeStore = useDismissibleNoticeStore(); - const { data, scrollRef, onLoadMore, hasNextPage, isFetchingNextPage } = - useExplorerViewContext(); - - const gridPadding = 2; - const scrollBarWidth = 6; - - const [width, setWidth] = useState(0); - const [lastSelectedIndex, setLastSelectedIndex] = useState(explorerStore.selectedRowIndex); - - // Virtualizer count calculation - const amountOfColumns = explorerStore.mediaColumns; - const amountOfRows = Math.ceil(data.length / amountOfColumns); - - // Virtualizer item size calculation - const itemSize = (width - gridPadding * 2 - scrollBarWidth) / amountOfColumns; - - const rowVirtualizer = useVirtualizer({ - count: amountOfRows, - getScrollElement: () => scrollRef.current, - estimateSize: () => (itemSize < 0 ? 0 : itemSize), - measureElement: () => itemSize, - paddingStart: gridPadding, - paddingEnd: gridPadding, - overscan: !dismissibleNoticeStore.mediaView ? 8 : 4 - }); - - const columnVirtualizer = useVirtualizer({ - horizontal: true, - count: amountOfColumns, - getScrollElement: () => scrollRef.current, - estimateSize: () => (itemSize < 0 ? 0 : itemSize), - measureElement: () => itemSize, - paddingStart: gridPadding, - paddingEnd: gridPadding - }); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const virtualColumns = columnVirtualizer.getVirtualItems(); - - useEffect(() => { - const lastRow = virtualRows[virtualRows.length - 1]; - if ( - (!lastRow || lastRow.index === amountOfRows - 1) && - hasNextPage && - !isFetchingNextPage - ) { - onLoadMore?.(); - } - }, [hasNextPage, onLoadMore, isFetchingNextPage, virtualRows, virtualColumns, data.length]); - - function handleWindowResize() { - if (scrollRef.current) { - setWidth(scrollRef.current.offsetWidth); - } - } - - // Resize view on initial render and reset selected item - useEffect(() => { - handleWindowResize(); - getExplorerStore().selectedRowIndex = null; - - return () => { - getExplorerStore().selectedRowIndex = null; - }; - }, []); - - // Resize view on window resize - useOnWindowResize(handleWindowResize); - - // Resize view on item selection/deselection - useEffect(() => { - const { selectedRowIndex } = explorerStore; - - setLastSelectedIndex(selectedRowIndex); - - if (explorerStore.showInspector && typeof lastSelectedIndex !== typeof selectedRowIndex) { - handleWindowResize(); - } - }, [explorerStore.selectedRowIndex]); - - // Resize view on inspector toggle - useEffect(() => { - if (explorerStore.selectedRowIndex !== null) { - handleWindowResize(); - } - }, [explorerStore.showInspector]); - - // Measure virtual item on size change - useEffect(() => { - rowVirtualizer.measure(); - columnVirtualizer.measure(); - }, [rowVirtualizer, columnVirtualizer, itemSize]); - - // Force recalculate range - // https://github.com/TanStack/virtual/issues/485 - useMemo(() => { - // @ts-ignore - rowVirtualizer.calculateRange(); - // @ts-ignore - columnVirtualizer.calculateRange(); - }, [amountOfRows, amountOfColumns, rowVirtualizer, columnVirtualizer]); - - // Select item with arrow up key - useKey('ArrowUp', (e) => { - e.preventDefault(); - - const { selectedRowIndex } = explorerStore; - - if (selectedRowIndex === null) return; - - getExplorerStore().selectedRowIndex = Math.max(selectedRowIndex - 1, 0); - }); - - // Select item with arrow down key - useKey('ArrowDown', (e) => { - e.preventDefault(); - - const { selectedRowIndex } = explorerStore; - - if (selectedRowIndex === null) return; - - getExplorerStore().selectedRowIndex = Math.min(selectedRowIndex + 1, data.length - 1); - }); - - if (!width) return null; - - return ( -
- {virtualRows.map((virtualRow) => ( - - {virtualColumns.map((virtualColumn, i) => { - const index = virtualRow.index * amountOfColumns + i; - const item = data[index]; - - if (!item) return null; - return ( -
- -
- ); - })} -
- ))} -
- ); -}; diff --git a/interface/app/$libraryId/Explorer/OptionsPanel.tsx b/interface/app/$libraryId/Explorer/OptionsPanel.tsx index beda53ba8..019d93c1d 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel.tsx +++ b/interface/app/$libraryId/Explorer/OptionsPanel.tsx @@ -12,7 +12,7 @@ import { const Heading = tw.div`text-ink-dull text-xs font-semibold`; const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`; -const sortOptions: Record = { +export const sortOptions: Record = { 'none': 'None', 'name': 'Name', 'sizeInBytes': 'Size', @@ -91,6 +91,7 @@ export default () => { +
{explorerStore.layoutMode === 'media' ? ( { - data: ExplorerItem; - index: number; - contextMenuClassName?: string; -} - -export const ViewItem = ({ - data, - index, - children, - contextMenuClassName, - ...props -}: ViewItemProps) => { - const explorerStore = useExplorerStore(); - const { library } = useLibraryContext(); - const navigate = useNavigate(); - - const { openFilePath } = usePlatform(); - const updateAccessTime = useLibraryMutation('files.updateAccessTime'); - const filePath = getItemFilePath(data); - - const explorerConfig = useExplorerConfigStore(); - - const onDoubleClick = () => { - if (isPath(data) && data.item.is_dir) { - navigate({ - pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`, - search: createSearchParams({ - path: `${data.item.materialized_path}${data.item.name}/` - }).toString() - }); - - getExplorerStore().selectedRowIndex = null; - } else if ( - openFilePath && - filePath && - explorerConfig.openOnDoubleClick && - !explorerStore.isRenaming - ) { - data.type === 'Path' && - data.item.object_id && - updateAccessTime.mutate(data.item.object_id); - openFilePath(library.uuid, filePath.id); - } else { - const { kind } = getExplorerItemData(data); - - if (['Video', 'Image', 'Audio'].includes(kind)) { - getExplorerStore().quickViewObject = data; - } - } - }; - - const onClick = (e: React.MouseEvent) => { - e.stopPropagation(); - getExplorerStore().selectedRowIndex = index; - }; - - return ( - -
(getExplorerStore().selectedRowIndex = index)} - {...props} - > - {children} -
-
- ); -}; - -interface Props { - data: ExplorerItem[]; - onLoadMore?(): void; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - viewClassName?: string; - listViewHeadersClassName?: string; - scrollRef?: React.RefObject; -} - -export default memo((props: Props) => { - const explorerStore = useExplorerStore(); - const layoutMode = explorerStore.layoutMode; - - const scrollRef = useRef(null); - - // Hide notice on overview page - const isOverview = useMatch('/:libraryId/overview'); - - return ( -
(getExplorerStore().selectedRowIndex = null)} - > - {!isOverview && } - - {layoutMode === 'grid' && } - {layoutMode === 'rows' && ( - - )} - {layoutMode === 'media' && } - -
- ); -}); diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx new file mode 100644 index 000000000..6dcfa3d85 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/GridView.tsx @@ -0,0 +1,110 @@ +import clsx from 'clsx'; +import { memo, useState } from 'react'; +import { ExplorerItem, formatBytes } from '@sd/client'; +import GridList from '~/components/GridList'; +import { useExplorerStore } from '~/hooks/useExplorerStore'; +import { ViewItem } from '.'; +import RenameTextBox from '../File/RenameTextBox'; +import FileThumb from '../File/Thumb'; +import { useExplorerViewContext } from '../ViewContext'; +import { getItemFilePath } from '../util'; + +interface GridViewItemProps { + data: ExplorerItem; + selected: boolean; + index: number; +} + +const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProps) => { + const filePathData = data ? getItemFilePath(data) : null; + const explorerStore = useExplorerStore(); + + return ( + +
+ +
+ +
+ {filePathData && ( + + )} + {explorerStore.showBytesInGridView && + (!explorerStore.isRenaming || (explorerStore.isRenaming && !selected)) && ( + + {formatBytes(Number(filePathData?.size_in_bytes || 0))} + + )} +
+
+ ); +}); + +export default () => { + const explorerStore = useExplorerStore(); + const explorerView = useExplorerViewContext(); + + const itemDetailsHeight = + explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0); + const itemHeight = explorerStore.gridItemSize + itemDetailsHeight; + + return ( + + {({ index, item: Item }) => { + if (!explorerView.items) { + return ( + +
+
+ {explorerStore.showBytesInGridView && ( +
+ )} + + ); + } + + const item = explorerView.items[index]; + if (!item) return null; + + const isSelected = Array.isArray(explorerView.selected) + ? explorerView.selected.includes(item.item.id) + : explorerView.selected === item.item.id; + + return ( + + + + ); + }} + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/ListView.tsx b/interface/app/$libraryId/Explorer/View/ListView.tsx new file mode 100644 index 000000000..7b83da166 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView.tsx @@ -0,0 +1,724 @@ +import { + ColumnDef, + ColumnSizingState, + Row, + flexRender, + getCoreRowModel, + useReactTable +} from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import byteSize from 'byte-size'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { CaretDown, CaretUp } from 'phosphor-react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; +import { useBoundingclientrect, useKey } from 'rooks'; +import useResizeObserver from 'use-resize-observer'; +import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client'; +import { + FilePathSearchOrderingKeys, + getExplorerStore, + useExplorerStore +} from '~/hooks/useExplorerStore'; +import { useScrolled } from '~/hooks/useScrolled'; +import { ViewItem } from '.'; +import RenameTextBox from '../File/RenameTextBox'; +import FileThumb from '../File/Thumb'; +import { InfoPill } from '../Inspector'; +import { useExplorerViewContext } from '../ViewContext'; +import { getExplorerItemData, getItemFilePath } from '../util'; + +interface ListViewItemProps { + row: Row; + columnSizing: ColumnSizingState; + paddingX: number; +} + +const ListViewItem = memo((props: ListViewItemProps) => { + return ( + +
+ {props.row.getVisibleCells().map((cell, i, cells) => { + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); + })} +
+
+ ); +}); + +export default () => { + const explorerStore = useExplorerStore(); + const explorerView = useExplorerViewContext(); + + const tableRef = useRef(null); + const tableHeaderRef = useRef(null); + const tableBodyRef = useRef(null); + + const [sized, setSized] = useState(false); + const [locked, setLocked] = useState(true); + const [columnSizing, setColumnSizing] = useState({}); + const [listOffset, setListOffset] = useState(0); + const [ranges, setRanges] = useState<[number, number][]>([]); + + const top = + (explorerView.top || 0) + + (explorerView.scrollRef.current + ? parseInt(getComputedStyle(explorerView.scrollRef.current).paddingTop) + : 0); + + const { isScrolled } = useScrolled( + explorerView.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 getObjectData = (data: ExplorerItem) => (isObject(data) ? data.item : data.item.object); + const getFileName = (path: FilePath) => `${path.name}${path.extension && `.${path.extension}`}`; + + const columns = useMemo[]>( + () => [ + { + id: 'name', + header: 'Name', + minSize: 200, + meta: { className: '!overflow-visible !text-ink' }, + accessorFn: (file) => { + const filePathData = getItemFilePath(file); + return filePathData && getFileName(filePathData); + }, + cell: (cell) => { + const file = cell.row.original; + const filePathData = getItemFilePath(file); + + const selectedId = Array.isArray(explorerView.selected) + ? explorerView.selected[0] + : explorerView.selected; + + const selected = selectedId === cell.row.original.item.id; + + return ( +
+
+ +
+ {filePathData && ( + 1 + } + activeClassName="absolute z-50 top-0.5 left-[58px] max-w-[calc(100%-60px)]" + /> + )} +
+ ); + } + }, + { + id: 'kind', + header: 'Type', + enableSorting: false, + accessorFn: (file) => { + return isPath(file) && file.item.is_dir + ? 'Folder' + : ObjectKind[getObjectData(file)?.kind || 0]; + }, + cell: (cell) => { + const file = cell.row.original; + return ( + + {isPath(file) && file.item.is_dir + ? 'Folder' + : ObjectKind[getObjectData(file)?.kind || 0]} + + ); + } + }, + { + id: 'sizeInBytes', + header: 'Size', + size: 100, + accessorFn: (file) => byteSize(Number(getItemFilePath(file)?.size_in_bytes || 0)) + }, + { + id: 'dateCreated', + header: 'Date Created', + accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY') + }, + { + header: 'Content ID', + enableSorting: false, + size: 180, + accessorFn: (file) => getExplorerItemData(file).casId + } + ], + [explorerView.selected, explorerStore.isRenaming] + ); + + const table = useReactTable({ + data: explorerView.items || [], + columns, + defaultColumn: { minSize: 100 }, + state: { columnSizing }, + onColumnSizingChange: setColumnSizing, + columnResizeMode: 'onChange', + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => String(row.item.id) + }); + + const tableLength = table.getTotalSize(); + const { rows } = table.getRowModel(); + + const rowVirtualizer = useVirtualizer({ + count: explorerView.items ? rows.length : 100, + getScrollElement: () => explorerView.scrollRef.current, + estimateSize: () => rowHeight, + paddingStart: paddingY + (isScrolled ? 35 : 0), + paddingEnd: paddingY, + scrollMargin: listOffset + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + const rect = useBoundingclientrect(tableRef); + + const selectedItems = useMemo(() => { + return Array.isArray(explorerView.selected) + ? new Set(explorerView.selected) + : explorerView.selected; + }, [explorerView.selected]); + + function handleResize() { + 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) < 10) { + setLocked(true); + } + } + } + + function handleRowClick( + e: React.MouseEvent, + row: Row + ) { + if (!explorerView.onSelectedChange) return; + + const rowIndex = row.index; + const itemId = row.original.item.id; + + if (e.shiftKey && Array.isArray(explorerView.selected)) { + const range = ranges[ranges.length - 1]; + if (!range) return; + + const [rangeStartId, rangeEndId] = range; + + const rowsById = table.getCoreRowModel().rowsById; + + const rangeStartRow = table.getRow(String(rangeStartId)); + const rangeEndRow = table.getRow(String(rangeEndId)); + + const lastDirection = rangeStartRow.index < rangeEndRow.index ? 'down' : 'up'; + const currentDirection = rangeStartRow.index < row.index ? 'down' : 'up'; + + const currentRowIndex = row.index; + + const rangeEndItem = rowsById[rangeEndId]; + if (!rangeEndItem) return; + + const isCurrentHigher = currentRowIndex > rangeEndItem.index; + + const indexes = isCurrentHigher + ? Array.from( + { + length: + currentRowIndex - + rangeEndItem.index + + (rangeEndItem.index === 0 ? 1 : 0) + }, + (_, i) => rangeStartRow.index + i + 1 + ) + : Array.from( + { length: rangeEndItem.index - currentRowIndex }, + (_, i) => rangeStartRow.index - (i + 1) + ); + + const updated = new Set(explorerView.selected); + if (isCurrentHigher) { + indexes.forEach((i) => { + updated.add(Number(rows[i]?.id)); + }); + } else { + indexes.forEach((i) => updated.add(Number(rows[i]?.id))); + } + + if (lastDirection !== currentDirection) { + const sorted = Math.abs(rangeStartRow.index - rangeEndItem.index); + + const indexes = Array.from({ length: sorted }, (_, i) => + rangeStartRow.index < rangeEndItem.index + ? rangeStartRow.index + (i + 1) + : rangeStartRow.index - (i + 1) + ); + + indexes.forEach( + (i) => i !== rangeStartRow.index && updated.delete(Number(rows[i]?.id)) + ); + } + explorerView.onSelectedChange?.([...updated]); + setRanges([...ranges.slice(0, ranges.length - 1), [rangeStartId, itemId]]); + } else if (e.metaKey && Array.isArray(explorerView.selected)) { + const updated = new Set(explorerView.selected); + if (updated.has(itemId)) { + updated.delete(itemId); + setRanges(ranges.filter((range) => range[0] !== rowIndex)); + } else { + setRanges([...ranges.slice(0, ranges.length - 1), [itemId, itemId]]); + } + + explorerView.onSelectedChange?.([...updated]); + } else if (e.button === 0) { + explorerView.onSelectedChange?.(explorerView.multiSelect ? [itemId] : itemId); + setRanges([[itemId, itemId]]); + } + } + + function handleRowContextMenu(row: Row) { + if (!explorerView.onSelectedChange || !explorerView.contextMenu) return; + + const itemId = row.original.item.id; + + if ( + !selectedItems || + (typeof selectedItems === 'object' && !selectedItems.has(itemId)) || + (typeof selectedItems === 'number' && selectedItems !== itemId) + ) { + explorerView.onSelectedChange(typeof selectedItems === 'object' ? [itemId] : itemId); + setRanges([[itemId, itemId]]); + } + } + + function isSelected(id: number) { + return typeof selectedItems === 'object' ? !!selectedItems.has(id) : selectedItems === id; + } + + useEffect(() => handleResize(), [tableWidth]); + + // TODO: Improve this + useEffect(() => { + setListOffset(tableRef.current?.offsetTop || 0); + }, [rect]); + + // Measure initial column widths + useEffect(() => { + if (tableRef.current) { + const columns = table.getAllColumns(); + const sizings = columns.reduce( + (sizings, column) => + column.id === 'name' ? sizings : { ...sizings, [column.id]: column.getSize() }, + {} as ColumnSizingState + ); + const scrollWidth = tableRef.current.offsetWidth; + const sizingsSum = Object.values(sizings).reduce((a, b) => a + b, 0); + const nameWidth = scrollWidth - paddingX * 2 - scrollBarWidth - sizingsSum; + table.setColumnSizing({ ...sizings, name: nameWidth }); + setSized(true); + } + }, []); + + // initialize ranges + useEffect(() => { + if (ranges.length === 0 && explorerView.selected) { + const id = Array.isArray(explorerView.selected) + ? explorerView.selected[explorerView.selected.length - 1] + : explorerView.selected; + + if (id) setRanges([[id, id]]); + } + }, []); + + // Load more items + useEffect(() => { + if (explorerView.onLoadMore) { + const lastRow = virtualRows[virtualRows.length - 1]; + if (lastRow) { + const rowsBeforeLoadMore = explorerView.rowsBeforeLoadMore || 1; + + const loadMoreOnIndex = + rowsBeforeLoadMore > rows.length || + lastRow.index > rows.length - rowsBeforeLoadMore + ? rows.length - 1 + : rows.length - rowsBeforeLoadMore; + + if (lastRow.index === loadMoreOnIndex) explorerView.onLoadMore(); + } + } + }, [virtualRows, rows.length, explorerView.rowsBeforeLoadMore, explorerView.onLoadMore]); + + useKey( + ['ArrowUp', 'ArrowDown'], + (e) => { + if (!explorerView.selectable) return; + + e.preventDefault(); + + if (explorerView.onSelectedChange) { + const lastSelectedItemId = Array.isArray(explorerView.selected) + ? explorerView.selected[explorerView.selected.length - 1] + : explorerView.selected; + + if (lastSelectedItemId) { + const lastSelectedRow = table.getRow(lastSelectedItemId.toString()); + + if (lastSelectedRow) { + const nextRow = + rows[ + e.key === 'ArrowUp' + ? lastSelectedRow.index - 1 + : lastSelectedRow.index + 1 + ]; + + if (nextRow) { + if (e.shiftKey && typeof selectedItems === 'object') { + const newSet = new Set(selectedItems); + + if ( + selectedItems?.has(Number(nextRow.id)) && + selectedItems?.has(Number(lastSelectedRow.id)) + ) { + newSet.delete(Number(lastSelectedRow.id)); + } else { + newSet.add(Number(nextRow.id)); + } + + explorerView.onSelectedChange([...newSet]); + setRanges([ + ...ranges.slice(0, ranges.length - 1), + // FIXME: Eslint is right here. + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + [ranges[ranges.length - 1]?.[0]!, Number(nextRow.id)] + ]); + } else { + explorerView.onSelectedChange( + explorerView.multiSelect + ? [Number(nextRow.id)] + : Number(nextRow.id) + ); + setRanges([[Number(nextRow.id), Number(nextRow.id)]]); + } + + if (explorerView.scrollRef.current) { + const tableBodyRect = tableBodyRef.current?.getBoundingClientRect(); + const scrollRect = + explorerView.scrollRef.current.getBoundingClientRect(); + + const paddingTop = parseInt( + getComputedStyle(explorerView.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); + + explorerView.scrollRef.current.scrollBy({ + top: scrollBy, + behavior: 'smooth' + }); + } else if (rowBottom > scrollRect.bottom) { + const scrollBy = + rowBottom - + scrollRect.height + + (nextRow.index === rows.length - 1 ? paddingY : 0); + + explorerView.scrollRef.current.scrollBy({ + top: scrollBy, + behavior: 'smooth' + }); + } + } + } + } + } + } + }, + { when: !explorerStore.isRenaming } + ); + + return ( +
+ {sized && ( + + <> + +
+
+ {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header, i) => { + const size = header.column.getSize(); + + const isSorted = + explorerStore.orderBy === header.id; + + return ( +
{ + if (header.column.getCanSort()) { + if (isSorted) { + getExplorerStore().orderByDirection = + explorerStore.orderByDirection === + 'Asc' + ? 'Desc' + : 'Asc'; + } else { + getExplorerStore().orderBy = + header.id as FilePathSearchOrderingKeys; + } + } + }} + > + {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + +
+ + {isSorted ? ( + explorerStore.orderByDirection === + 'Asc' ? ( + + ) : ( + + ) + ) : null} + + {(i !== + headerGroup.headers.length - + 1 || + (i === + headerGroup.headers.length - + 1 && + !locked)) && ( +
+ e.stopPropagation() + } + onMouseDown={(e) => { + setLocked(false); + header.getResizeHandler()( + e + ); + }} + onTouchStart={header.getResizeHandler()} + className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-app-line/50" + /> + )} +
+ )} +
+ ); + })} +
+ ))} +
+
+ + + +
+
+ {virtualRows.map((virtualRow) => { + if (!explorerView.items) { + return ( +
+
+
+ ); + } + + const row = rows[virtualRow.index]; + if (!row) return null; + + const selected = isSelected(row.original.item.id); + + const previousRow = rows[virtualRow.index - 1]; + const selectedPrior = + previousRow && isSelected(previousRow.original.item.id); + + const nextRow = rows[virtualRow.index + 1]; + const selectedNext = + nextRow && isSelected(nextRow.original.item.id); + + return ( +
+
handleRowClick(e, row)} + onContextMenu={() => handleRowContextMenu(row)} + className={clsx( + 'relative flex h-full w-full rounded-md border', + virtualRow.index % 2 === 0 && + 'bg-[#00000006] dark:bg-[#00000030]', + 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/MediaView.tsx b/interface/app/$libraryId/Explorer/View/MediaView.tsx new file mode 100644 index 000000000..5719b1e69 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/MediaView.tsx @@ -0,0 +1,97 @@ +import clsx from 'clsx'; +import { ArrowsOutSimple } from 'phosphor-react'; +import { memo } from 'react'; +import { ExplorerItem } from '@sd/client'; +import { Button } from '@sd/ui'; +import GridList from '~/components/GridList'; +import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; +import { ViewItem } from '.'; +import FileThumb from '../File/Thumb'; +import { useExplorerViewContext } from '../ViewContext'; + +interface MediaViewItemProps { + data: ExplorerItem; + index: number; + selected: boolean; +} + +const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => { + const explorerStore = useExplorerStore(); + + return ( + +
+ + + +
+
+ ); +}); + +export default () => { + const explorerStore = useExplorerStore(); + const explorerView = useExplorerViewContext(); + + return ( + + {({ index, item: Item }) => { + if (!explorerView.items) { + return ( + +
+ + ); + } + + const item = explorerView.items[index]; + if (!item) return null; + + const isSelected = Array.isArray(explorerView.selected) + ? explorerView.selected.includes(item.item.id) + : explorerView.selected === item.item.id; + + return ( + + + + ); + }} + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx new file mode 100644 index 000000000..4e396f287 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -0,0 +1,161 @@ +import clsx from 'clsx'; +import { + Cards, + Columns, + FolderNotchOpen, + GridFour, + MonitorPlay, + Rows, + SquaresFour +} from 'phosphor-react'; +import { HTMLAttributes, PropsWithChildren, ReactNode, memo, useState } from 'react'; +import { createSearchParams, useNavigate } from 'react-router-dom'; +import { useKey } from 'rooks'; +import { ExplorerItem, isPath, useLibraryContext, useLibraryMutation } from '@sd/client'; +import { ContextMenu } from '@sd/ui'; +import { useExplorerConfigStore } from '~/hooks'; +import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '~/hooks'; +import { usePlatform } from '~/util/Platform'; +import { + ExplorerViewContext, + ExplorerViewSelection, + ViewContext, + useExplorerViewContext +} from '../ViewContext'; +import { getItemFilePath } from '../util'; +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 explorerStore = useExplorerStore(); + const explorerView = useExplorerViewContext(); + const { library } = useLibraryContext(); + const navigate = useNavigate(); + + const { openFilePath } = usePlatform(); + const updateAccessTime = useLibraryMutation('files.updateAccessTime'); + const filePath = getItemFilePath(data); + + const explorerConfig = useExplorerConfigStore(); + + const onDoubleClick = () => { + if (isPath(data) && data.item.is_dir) { + navigate({ + pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`, + search: createSearchParams({ + path: `${data.item.materialized_path}${data.item.name}/` + }).toString() + }); + } else if ( + openFilePath && + filePath && + explorerConfig.openOnDoubleClick && + !explorerStore.isRenaming + ) { + if (data.type === 'Path' && data.item.object_id) { + updateAccessTime.mutate(data.item.object_id); + } + + openFilePath(library.uuid, filePath.id); + } + }; + + return ( + + {children} +
+ } + onOpenChange={explorerView.setIsContextMenuOpen} + disabled={!explorerView.contextMenu} + asChild={false} + > + {explorerView.contextMenu} + + ); +}; + +interface Props + extends Omit, 'multiSelect' | 'selectable'> { + layout: ExplorerLayoutMode; + className?: string; + emptyNotice?: ReactNode; +} + +export default memo(({ layout, className, emptyNotice, ...contextProps }) => { + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + + useKey('Space', (e) => { + e.preventDefault(); + + if (!getExplorerStore().quickViewObject) { + const selectedItem = contextProps.items?.find( + (item) => + item.item.id === + (Array.isArray(contextProps.selected) + ? contextProps.selected[0] + : contextProps.selected) + ); + + if (selectedItem) { + getExplorerStore().quickViewObject = selectedItem; + } + } + }); + + const emptyNoticeIcon = () => { + let Icon; + + switch (layout) { + case 'grid': + Icon = GridFour; + break; + case 'media': + Icon = MonitorPlay; + break; + case 'columns': + Icon = Columns; + break; + case 'rows': + Icon = Rows; + break; + } + + return ; + }; + + return ( +
+ {contextProps.items === null || + (contextProps.items && contextProps.items.length > 0) ? ( + + {layout === 'grid' && } + {layout === 'rows' && } + {layout === 'media' && } + + ) : emptyNotice === null ? null : ( + emptyNotice || ( +
+ {emptyNoticeIcon()} +

This list is empty

+
+ ) + )} +
+ ); +}) as (props: Props) => JSX.Element; diff --git a/interface/app/$libraryId/Explorer/ViewContext.ts b/interface/app/$libraryId/Explorer/ViewContext.ts index 8e0909d5e..e42b23750 100644 --- a/interface/app/$libraryId/Explorer/ViewContext.ts +++ b/interface/app/$libraryId/Explorer/ViewContext.ts @@ -1,15 +1,25 @@ -import { RefObject, createContext, useContext } from 'react'; +import { ReactNode, RefObject, createContext, useContext } from 'react'; import { ExplorerItem } from '@sd/client'; -interface Context { - data: ExplorerItem[]; +export type ExplorerViewSelection = number | number[]; + +export interface ExplorerViewContext { + items: ExplorerItem[] | null; scrollRef: RefObject; - isFetchingNextPage?: boolean; - onLoadMore?(): void; - hasNextPage?: boolean; + selected?: T; + onSelectedChange?: (selected: T) => void; + overscan?: number; + onLoadMore?: () => void; + rowsBeforeLoadMore?: number; + top?: number; + multiSelect?: boolean; + contextMenu?: ReactNode; + setIsContextMenuOpen?: (isOpen: boolean) => void; + selectable?: boolean; + padding?: number | { x?: number; y?: number }; } -export const ViewContext = createContext(null); +export const ViewContext = createContext(null); export const useExplorerViewContext = () => { const ctx = useContext(ViewContext); diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 060b0f3ff..e317c892b 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -1,39 +1,38 @@ -import clsx from 'clsx'; -import { ReactNode, useEffect, useMemo } from 'react'; -import { useKey } from 'rooks'; +import { FolderNotchOpen } from 'phosphor-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ExplorerItem, useLibrarySubscription } from '@sd/client'; -import { getExplorerStore, useExplorerStore, useKeyDeleteFile } from '~/hooks'; +import { useExplorerStore, useKeyDeleteFile } from '~/hooks'; +import { TOP_BAR_HEIGHT } from '../TopBar'; import ExplorerContextMenu from './ContextMenu'; +import DismissibleNotice from './DismissibleNotice'; +import ContextMenu from './File/ContextMenu'; import { Inspector } from './Inspector'; import View from './View'; import { useExplorerSearchParams } from './util'; interface Props { - // TODO: not using data since context isn't actually used - // and it's not exactly compatible with search - // data?: ExplorerData; - items?: ExplorerItem[]; + items: ExplorerItem[] | null; onLoadMore?(): void; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - viewClassName?: string; - children?: ReactNode; - inspectorClassName?: string; - explorerClassName?: string; - listViewHeadersClassName?: string; - scrollRef?: React.RefObject; } export default function Explorer(props: Props) { - const { selectedRowIndex, ...expStore } = useExplorerStore(); + const INSPECTOR_WIDTH = 260; + + const explorerStore = useExplorerStore(); + const [{ path }] = useExplorerSearchParams(); - const selectedItem = useMemo(() => { - if (selectedRowIndex === null) return null; - return props.items?.[selectedRowIndex] ?? null; - }, [selectedRowIndex, props.items]); + const scrollRef = useRef(null); - useKeyDeleteFile(selectedItem, expStore.locationId); + const [selectedItemId, setSelectedItemId] = useState(); + + const selectedItem = useMemo( + () => + selectedItemId + ? props.items?.find((item) => item.item.id === selectedItemId) + : undefined, + [selectedItemId] + ); useLibrarySubscription(['jobs.newThumbnail'], { onStarted: () => { @@ -44,51 +43,54 @@ export default function Explorer(props: Props) { }, onData: (cas_id) => { console.log({ cas_id }); - expStore.addNewThumbnail(cas_id); + explorerStore.addNewThumbnail(cas_id); } }); - useEffect(() => { - getExplorerStore().selectedRowIndex = null; - }, [path]); + useKeyDeleteFile(selectedItem || null, explorerStore.locationId); - useKey('Space', (e) => { - e.preventDefault(); - - if (selectedItem) { - if (expStore.quickViewObject?.item.id === selectedItem.item.id) { - getExplorerStore().quickViewObject = null; - } else { - getExplorerStore().quickViewObject = selectedItem; - } - } - }); + useEffect(() => setSelectedItemId(undefined), [path]); return ( -
-
-
- {props.children} - - {props.items && ( - - )} - -
- {expStore.showInspector && ( -
- + <> + +
+
+ + } + emptyNotice={ +
+ +

This folder is empty

+
+ } + />
- )} -
-
+
+ + + {explorerStore.showInspector && ( + + )} + ); } diff --git a/interface/app/$libraryId/Layout/Sidebar/JobManager/useGroupedJobs.ts b/interface/app/$libraryId/Layout/Sidebar/JobManager/useGroupedJobs.ts index 4675684cb..1b8808b66 100644 --- a/interface/app/$libraryId/Layout/Sidebar/JobManager/useGroupedJobs.ts +++ b/interface/app/$libraryId/Layout/Sidebar/JobManager/useGroupedJobs.ts @@ -9,7 +9,21 @@ export interface IJobGroup extends JobReport { export function useGroupedJobs(jobs: JobReport[] = [], runningJobs: JobReport[] = []) { return useMemo(() => { return jobs.reduce((arr, job) => { - const childJobs = jobs.filter((j) => j.parent_id === job.id); + const childJobs = jobs + .filter((j) => j.parent_id === job.id || j.id === job.id) + // sort by started_at, a string date that is possibly null + .sort((a, b) => { + if (!a.started_at && !b.started_at) { + return 0; + } + + if (!a.started_at) { + // a is null + return 1; + } + + return a.started_at.localeCompare(b.started_at || ''); + }); if (!jobs.some((j) => j.id === job.parent_id)) { arr.push({ diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx index 7e568d397..86ec68af2 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx @@ -12,7 +12,7 @@ export default () => { { } > - + {/* {node.data?.name} + */} + + + Jamie's MBP + + + + spacephone + + + + titan {/* {(locations.data?.length || 0) < 4 && ( }> + + + + + + } /> + Configure Location + + + + Re-index + Regenerate Thumbs + + + + Archive + + + + +
+ ) +} diff --git a/interface/app/$libraryId/overview/data.ts b/interface/app/$libraryId/overview/data.ts index 35bff0cdf..acd4241b8 100644 --- a/interface/app/$libraryId/overview/data.ts +++ b/interface/app/$libraryId/overview/data.ts @@ -12,6 +12,7 @@ import { useRspcLibraryContext } from '@sd/client'; import { useExplorerStore } from '~/hooks'; +import { useExplorerOrder } from '../Explorer/util'; export const IconForCategory: Partial> = { Recents: iconNames.Collection, @@ -126,14 +127,21 @@ export function useItems(selectedCategory: Category) { [objectsQuery.data] ); + const loadMore = () => { + const query = isObjectQuery ? objectsQuery : pathsQuery; + if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage(); + }; + return isObjectQuery ? { items: objectsItems, - query: objectsQuery + query: objectsQuery, + loadMore } : { items: pathsItems, - query: pathsQuery + query: pathsQuery, + loadMore }; } diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index a7b2ae2ff..4e6017b2f 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -1,9 +1,12 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import 'react-loading-skeleton/dist/skeleton.css'; +import { useKey } from 'rooks'; +import { Category } from '@sd/client'; import { z } from '@sd/ui/src/forms'; -import { Category } from '~/../packages/client/src'; -import { useExplorerTopBarOptions } from '~/hooks'; -import Explorer from '../Explorer'; +import { getExplorerStore, useExplorerStore, useExplorerTopBarOptions } from '~/hooks'; +import ContextMenu from '../Explorer/File/ContextMenu'; +import { Inspector } from '../Explorer/Inspector'; +import View from '../Explorer/View'; import { SEARCH_PARAMS } from '../Explorer/util'; import { usePageLayout } from '../PageLayout'; import { TopBarPortal } from '../TopBar/Portal'; @@ -15,13 +18,23 @@ import { useItems } from './data'; export type SearchArgs = z.infer; export const Component = () => { + const explorerStore = useExplorerStore(); + const page = usePageLayout(); + const { explorerViewOptions, explorerControlOptions, explorerToolOptions } = useExplorerTopBarOptions(); const [selectedCategory, setSelectedCategory] = useState('Recents'); - const { items, query } = useItems(selectedCategory); + const { items, query, loadMore } = useItems(selectedCategory); + + const [selectedItemId, setSelectedItemId] = useState(); + + const selectedItem = useMemo( + () => (selectedItemId ? items?.find((item) => item.item.id === selectedItemId) : undefined), + [selectedItemId] + ); return ( <> @@ -32,20 +45,37 @@ export const Component = () => { /> } /> - - + +
+ + - + +
+ } + emptyNotice={null} + /> + + {explorerStore.showInspector && ( + + )} +
+
); }; diff --git a/interface/app/$libraryId/settings/client/appearance.tsx b/interface/app/$libraryId/settings/client/appearance.tsx index b84757ebd..791e5b796 100644 --- a/interface/app/$libraryId/settings/client/appearance.tsx +++ b/interface/app/$libraryId/settings/client/appearance.tsx @@ -233,15 +233,15 @@ function SystemTheme(props: ThemeProps) { return (
-
- +
+
- +
{props.isSelected && ( { control={form.control} render={({ field }) => { return ( -
+
{ + count: number; + scrollRef: RefObject; + padding?: number | { x?: number; y?: number }; + gap?: number | { x?: number; y?: number }; + children: (props: { + index: number; + item: (props: GridListItemProps) => JSX.Element; + }) => JSX.Element | null; + selected?: T; + onSelectedChange?: (change: T) => void; + selectable?: boolean; + onSelect?: (index: number) => void; + onDeselect?: (index: number) => void; + overscan?: number; + top?: number; + onLoadMore?: () => void; + rowsBeforeLoadMore?: number; + preventSelection?: boolean; + preventContextMenuSelection?: boolean; +} +interface WrapProps extends GridListDefaults { + size: number | { width: number; height: number }; +} + +interface ResizeProps extends GridListDefaults { + columns: number; +} + +type GridListProps = WrapProps | ResizeProps; + +export default ({ selectable = true, ...props }: GridListProps) => { + const scrollBarWidth = 6; + + const multiSelect = Array.isArray(props.selected); + + const paddingX = (typeof props.padding === 'object' ? props.padding.x : props.padding) || 0; + const paddingY = (typeof props.padding === 'object' ? props.padding.y : props.padding) || 0; + + const gapX = (typeof props.gap === 'object' ? props.gap.x : props.gap) || 0; + const gapY = (typeof props.gap === 'object' ? props.gap.y : props.gap) || 0; + + const itemWidth = + 'size' in props + ? typeof props.size === 'object' + ? props.size.width + : props.size + : undefined; + + const itemHeight = + 'size' in props + ? typeof props.size === 'object' + ? props.size.height + : props.size + : undefined; + + const ref = useRef(null); + + const { width = 0 } = useResizeObserver({ ref: ref }); + + const rect = useBoundingclientrect(ref); + + const selecto = useRef(null); + + const [scrollOptions, setScrollOptions] = React.useState(); + const [listOffset, setListOffset] = useState(0); + + const gridWidth = width - (paddingX || 0) * 2; + + // Virtualizer count calculation + const amountOfColumns = + 'columns' in props ? props.columns : itemWidth ? Math.floor(gridWidth / itemWidth) : 0; + const amountOfRows = amountOfColumns > 0 ? Math.ceil(props.count / amountOfColumns) : 0; + + // Virtualizer item size calculation + const virtualItemWidth = amountOfColumns > 0 ? gridWidth / amountOfColumns : 0; + const virtualItemHeight = itemHeight || virtualItemWidth; + + const rowVirtualizer = useVirtualizer({ + count: amountOfRows, + getScrollElement: () => props.scrollRef.current, + estimateSize: () => virtualItemHeight, + measureElement: () => virtualItemHeight, + paddingStart: paddingY, + paddingEnd: paddingY, + overscan: props.overscan, + scrollMargin: listOffset + }); + + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: amountOfColumns, + getScrollElement: () => props.scrollRef.current, + estimateSize: () => virtualItemWidth, + measureElement: () => virtualItemWidth, + paddingStart: paddingX, + paddingEnd: paddingX + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const virtualColumns = columnVirtualizer.getVirtualItems(); + + // Measure virtual item on size change + useEffect(() => { + rowVirtualizer.measure(); + columnVirtualizer.measure(); + }, [rowVirtualizer, columnVirtualizer, virtualItemWidth, virtualItemHeight]); + + // Force recalculate range + // https://github.com/TanStack/virtual/issues/485 + useMemo(() => { + // @ts-ignore + rowVirtualizer.calculateRange(); + // @ts-ignore + columnVirtualizer.calculateRange(); + }, [amountOfRows, amountOfColumns, rowVirtualizer, columnVirtualizer]); + + // Set Selecto scroll options + useEffect(() => { + setScrollOptions({ + container: props.scrollRef.current!, + getScrollPosition: () => { + // FIXME: Eslint is right here. + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + return [props.scrollRef.current?.scrollLeft!, props.scrollRef.current?.scrollTop!]; + }, + throttleTime: 30, + threshold: 0 + }); + }, []); + + // Check Selecto scroll + useEffect(() => { + const handleScroll = () => { + selecto.current?.checkScroll(); + }; + + props.scrollRef.current?.addEventListener('scroll', handleScroll); + return () => props.scrollRef.current?.removeEventListener('scroll', handleScroll); + }, []); + + useEffect(() => { + setListOffset(ref.current?.offsetTop || 0); + }, [rect]); + + // Handle key Selection + useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'], (e) => { + !props.preventSelection && e.preventDefault(); + + if (!selectable || !props.onSelectedChange || props.preventSelection) return; + + const selectedItems = selecto.current?.getSelectedTargets() || [ + ...document.querySelectorAll(`[data-selected="true"]`) + ]; + + const lastItem = selectedItems[selectedItems.length - 1]; + + if (lastItem) { + const currentIndex = Number(lastItem.getAttribute('data-selectable-index')); + let newIndex = currentIndex; + + switch (e.key) { + case 'ArrowUp': + newIndex += -amountOfColumns; + break; + case 'ArrowDown': + newIndex += amountOfColumns; + break; + case 'ArrowRight': + newIndex += 1; + break; + case 'ArrowLeft': + newIndex += -1; + break; + } + + const newSelectedItem = document.querySelector( + `[data-selectable-index="${newIndex}"]` + ); + + if (newSelectedItem) { + if (!multiSelect) { + const id = Number(newSelectedItem.getAttribute('data-selectable-id')); + props.onSelectedChange(id as T); + } else { + const addToGridListSelection = e.shiftKey; + + selecto.current?.setSelectedTargets([ + ...(addToGridListSelection ? selectedItems : []), + newSelectedItem + ]); + + props.onSelectedChange( + [...(addToGridListSelection ? selectedItems : []), newSelectedItem].map( + (el) => Number(el.getAttribute('data-selectable-id')) + ) as T + ); + } + + if (props.scrollRef.current) { + const direction = newIndex > currentIndex ? 'down' : 'up'; + + const itemRect = newSelectedItem.getBoundingClientRect(); + const scrollRect = props.scrollRef.current.getBoundingClientRect(); + + const paddingTop = parseInt( + getComputedStyle(props.scrollRef.current).paddingTop + ); + + const top = props.top ? paddingTop + props.top : paddingTop; + + switch (direction) { + case 'up': { + if (itemRect.top < top) { + props.scrollRef.current.scrollBy({ + top: itemRect.top - top - paddingY - 1, + behavior: 'smooth' + }); + } + break; + } + case 'down': { + if (itemRect.bottom > scrollRect.height) { + props.scrollRef.current.scrollBy({ + top: itemRect.bottom - scrollRect.height + paddingY + 1, + behavior: 'smooth' + }); + } + break; + } + } + } + } + } + }); + + useEffect(() => { + if (props.onLoadMore) { + const lastRow = virtualRows[virtualRows.length - 1]; + if (lastRow) { + const rowsBeforeLoadMore = props.rowsBeforeLoadMore || 1; + + const loadMoreOnIndex = + rowsBeforeLoadMore > amountOfRows || + lastRow.index > amountOfRows - rowsBeforeLoadMore + ? amountOfRows - 1 + : amountOfRows - rowsBeforeLoadMore; + + if (lastRow.index === loadMoreOnIndex) props.onLoadMore(); + } + } + }, [virtualRows, amountOfRows, props.rowsBeforeLoadMore, props.onLoadMore]); + + return ( +
+ {multiSelect && ( + { + if (e.inputEvent.target.nodeName === 'BUTTON') { + return false; + } + return true; + }} + onScroll={(e) => { + selecto.current; + props.scrollRef.current?.scrollBy( + e.direction[0]! * 10, + e.direction[1]! * 10 + ); + }} + onSelect={(e) => { + const set = new Set(props.selected as number[]); + + e.removed.forEach((el) => { + set.delete(Number(el.getAttribute('data-selectable-id'))); + }); + + e.added.forEach((el) => { + set.add(Number(el.getAttribute('data-selectable-id'))); + }); + + props.onSelectedChange?.([...set] as T); + }} + /> + )} + + {width !== 0 && ( + + {virtualRows.map((virtualRow) => ( + + {virtualColumns.map((virtualColumn) => { + const index = + virtualRow.index * amountOfColumns + virtualColumn.index; + const item = props.children({ index, item: GridListItem }); + + if (!item) return null; + return ( +
+ {cloneElement(item, { + selectable: selectable && !!props.onSelectedChange, + index, + style: { width: itemWidth }, + onClick: (id) => { + !multiSelect && props.onSelectedChange?.(id as T); + }, + onContextMenu: (id) => { + !props.preventContextMenuSelection && + !multiSelect && + props.onSelectedChange?.(id as T); + } + })} +
+ ); + })} +
+ ))} +
+ )} +
+ ); +}; + +const SelectoContext = createContext>(undefined!); +const useSelecto = () => useContext(SelectoContext); + +interface GridListItemProps + extends PropsWithChildren, + Omit, 'id' | 'onClick' | 'onContextMenu'> { + selectable?: boolean; + index?: number; + selected?: boolean; + id?: number; + onClick?: (id: number) => void; + onContextMenu?: (id: number) => void; +} + +const GridListItem = ({ className, children, style, ...props }: GridListItemProps) => { + const ref = useRef(null); + const selecto = useSelecto(); + + useEffect(() => { + if (props.selectable && props.selected && selecto.current) { + const current = selecto.current.getSelectedTargets(); + selecto.current?.setSelectedTargets([ + ...current.filter( + (el) => el.getAttribute('data-selectable-id') !== String(props.id) + ), + ref.current! + ]); + } + }, []); + + const selectableProps = props.selectable + ? { + 'data-selectable': '', + 'data-selectable-id': props.id || props.index, + 'data-selectable-index': props.index, + 'data-selected': props.selected + } + : {}; + + return ( +
{ + if (props.onClick && props.selectable) { + const id = props.id || props.index; + if (id) props.onClick(id); + } + }} + onContextMenu={() => { + if (props.onContextMenu && props.selectable) { + const id = props.id || props.index; + if (id) props.onContextMenu(id); + } + }} + > + {children} +
+ ); +}; diff --git a/interface/hooks/useDragSelect.tsx b/interface/hooks/useDragSelect.tsx new file mode 100644 index 000000000..2f624bd9e --- /dev/null +++ b/interface/hooks/useDragSelect.tsx @@ -0,0 +1,39 @@ +import DragSelect from 'dragselect'; +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type ProviderProps = { + children: React.ReactNode; + settings?: ConstructorParameters[0]; +}; + +const Context = createContext(undefined); + +function DragSelectProvider({ children, settings = {} }: ProviderProps) { + const [ds, setDS] = useState(); + + useEffect(() => { + setDS((prevState) => { + if (prevState) return prevState; + return new DragSelect({}); + }); + return () => { + if (ds) { + console.log('stop'); + ds.stop(); + setDS(undefined); + } + }; + }, [ds]); + + useEffect(() => { + ds?.setSettings(settings); + }, [ds, settings]); + + return {children}; +} + +function useDragSelect() { + return useContext(Context); +} + +export { DragSelectProvider, useDragSelect }; diff --git a/interface/hooks/useExplorerStore.tsx b/interface/hooks/useExplorerStore.tsx index c5cfb657b..1c8c40bb8 100644 --- a/interface/hooks/useExplorerStore.tsx +++ b/interface/hooks/useExplorerStore.tsx @@ -1,4 +1,5 @@ import { proxy, useSnapshot } from 'valtio'; +import { proxyMap, proxySet } from 'valtio/utils'; import { z } from 'zod'; import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering } from '@sd/client'; import { resetStore } from '@sd/client'; diff --git a/interface/package.json b/interface/package.json index 24782373d..95d458189 100644 --- a/interface/package.json +++ b/interface/package.json @@ -35,7 +35,8 @@ "@tanstack/react-query": "^4.12.0", "@tanstack/react-query-devtools": "^4.22.0", "@tanstack/react-table": "^8.8.5", - "@tanstack/react-virtual": "3.0.0-beta.18", + "@tanstack/react-virtual": "3.0.0-beta.54", + "@types/react-scroll-sync": "^0.8.4", "@vitejs/plugin-react": "^2.1.0", "autoprefixer": "^10.4.12", "byte-size": "^8.1.0", @@ -43,6 +44,7 @@ "clsx": "^1.2.1", "crypto-random-string": "^5.0.0", "dayjs": "^1.11.5", + "dragselect": "^2.7.4", "framer-motion": "^10.11.5", "phosphor-react": "^1.4.1", "react": "^18.2.0", @@ -55,12 +57,15 @@ "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.22.3", "remix-params-helper": "^0.4.10", "rooks": "^5.14.0", "tailwindcss": "^3.3.2", "ts-deepmerge": "^6.0.3", "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", + "use-resize-observer": "^9.1.0", "valtio": "^1.7.4" }, "devDependencies": { diff --git a/packages/ui/src/ContextMenu.tsx b/packages/ui/src/ContextMenu.tsx index b7c1782e8..3010bb35a 100644 --- a/packages/ui/src/ContextMenu.tsx +++ b/packages/ui/src/ContextMenu.tsx @@ -6,6 +6,8 @@ import { PropsWithChildren, Suspense, createContext, useContext } from 'react'; interface ContextMenuProps extends RadixCM.MenuContentProps { trigger: React.ReactNode; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; } export const contextMenuClassNames = clsx( @@ -20,10 +22,19 @@ export const contextMenuClassNames = clsx( const context = createContext(false); export const useContextMenu = () => useContext(context); -const Root = ({ trigger, children, className, ...props }: ContextMenuProps) => { +const Root = ({ + trigger, + children, + className, + onOpenChange, + disabled, + ...props +}: ContextMenuProps) => { return ( - - {trigger} + + disabled && e.preventDefault()}> + {trigger} + {children} diff --git a/packages/ui/src/Dialog.tsx b/packages/ui/src/Dialog.tsx index 5c9eb2ea8..c5f10e298 100644 --- a/packages/ui/src/Dialog.tsx +++ b/packages/ui/src/Dialog.tsx @@ -108,7 +108,7 @@ const AnimatedDialogOverlay = animated(RDialog.Overlay); export interface DialogProps extends RDialog.DialogProps, - Omit, 'onSubmit'> { + Omit, 'onSubmit'> { title?: string; dialog: ReturnType; loading?: boolean; diff --git a/packages/ui/src/Popover.tsx b/packages/ui/src/Popover.tsx index 6f6468448..d1475edce 100644 --- a/packages/ui/src/Popover.tsx +++ b/packages/ui/src/Popover.tsx @@ -1,12 +1,17 @@ import * as Radix from '@radix-ui/react-popover'; import clsx from 'clsx'; import React, { useEffect, useRef, useState } from 'react'; +import { tw } from '.'; interface Props extends Radix.PopoverContentProps { trigger: React.ReactNode; disabled?: boolean; } +export const PopoverContainer = tw.div`flex flex-col p-1.5`; +export const PopoverSection = tw.div`flex flex-col`; +export const PopoverDivider = tw.div`my-2 border-b border-app-line`; + export const Popover = ({ trigger, children, disabled, className, ...props }: Props) => { const triggerRef = useRef(null); diff --git a/packages/ui/style/colors.scss b/packages/ui/style/colors.scss index 36fcec12c..5bb0de296 100644 --- a/packages/ui/style/colors.scss +++ b/packages/ui/style/colors.scss @@ -94,7 +94,7 @@ // menu --color-menu: var(--light-hue), 5%, 100%; --color-menu-line: var(--light-hue), 5%, 95%; - --color-menu-ink: var(--light-hue), 5%, 100%; + --color-menu-ink: var(--light-hue), 5%, 20%; --color-menu-faint: var(--light-hue), 5%, 80%; --color-menu-hover: var(--light-hue), 15%, 20%; --color-menu-selected: var(--light-hue), 5%, 30%; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9742bb35b215e7bef6928714c95af9d37f105002..308b554de92bfb188ccb2da2cbfd1cb9f52a050e 100644 GIT binary patch delta 3555 zcmbtWU98(?8P;i&)ApRxt=-x~x2atx7+ve0#BuDz8A3gd^XnwGDip}cDP7A7 zLn1ehzVGw?Z{p*k=k;~XSUusyS%&;MD({Vb9e25psmZ& zdJKXR=@s|EdQf^E5%z8v8<7c;im^ks(E8O`j+|u?HZqFaiuPCCI z6fIZY1fvUo-*cKP1q#uaTJ=2IC^m3wBzHL6@f&3)kbIeL227sP%etN8>SDJmm}y?Y zRjZo`q)|g?4Z9O&Vp^V!=;JcL>a?Yp83dps;j!4gcgX5sCzol@qmKMj51vd%C%?9O zZ2R)tdmj7z?Lydh$DWoP`?}$6rlM!SgJ-z*)b9ec3#ib*wmQSytSKO-IqcA7q$5%)!)(#iQ`dN3hrgGc#c#YpdVp!~qzE4T05$ql9K zd^<<6CAQEM`4*7sF-1zk`SB3Z@VwfkV9G0`L8Bv5c-W?}L8qaqb;Qjxg^{1M{Cq%XeM4_24)Oiv1bLwP~^ZdEfd$-S>jzv%3F3KYzxtN2+utcx?d_T_@iqesBN1S?MN#&C!=iq@YA<<>cabYSUX3Bv0nh+>lM(_honIP;@xh zf9%MucBA`J`v4A`bzzF_gFrSM$%@IBni(x9NJ9`0TbPq9z_MZt6wPNUI7Me0`BHAi zDO#Co>1VNHtBYDq!FTl_I{ckA zfFs(qyheh)3AJ1U==wl+>Wo3FuGU3KHVaLokfH*U%nbyboeCwom-V~oC?jcYwNVt9 zoFg(>7Z_qjqcdtLWZ0F8K>HKQxNG}Yuf^{B{T>94KK#il%%;pjLxDNE@A?&{*K>8; z2$6n=%{R2r@C*}{G_m5aeu2pJ-KMCP%^}(?%Mi=6AR(-HWC_<@LfKK{-U5?y>N{#kC|52ubq zLv9z+alFvRN@Ib{SJH}J)S;Zfjvz+!tJW|?3oNVSk_e-=VOJ`yT%Veg6kWv$7$Z2Y z*bemE`JJ>R0Vu#7Wiz~SjBGR1(NHnjUQL9KRpGJ16B;ECu^ zc<@m4{@>zEfwbR^K%Ctpe zG*G8}FbrntiCbqUS*uv1(*d1q%b~;$kT&KB=*&jyC8sv3^s`p0q4Gd4#biNhmi3f8 zSsYH16(B6;N?HT1M2EfqhR5|4(epNZYh#Yc=)$FCfU95yMr%8?h)zpR(azvT4H%E< zt_qYId3{7$5F^*aYO>bEN1|Dwi415VGeZiAa+Vx;tkdE9l2RrpBsCaWfzp`pgua+# z@Rn2Yu{qwIjl}@d&&7^yf4jTy+0B(*(G~qoD0=<=eP>wEDH(7u@xrQUwAfKjb_+Pp z!mSp~H9SwPVcl`f(LCBkdD_t6e`KR`6y}u@y^3-yN8#|g7(J!r@)OHZ!{tRg)fp)bb&kCknm`@2rLadYLsrn2|s(qO$ zb|^}(=8|w9&3AL-DKsf9y0Sc!L(v-Ts*9Z>v1*_*jY@jZRam*hc+i;dw}}*Q7YDYE z4*eMy<_WKpO-@IUGltqsXXuaQe4X@bWI&h(*9{mwr&5HUDvW)t!(iGk2aBR=!rBs=agw>*3Twl7Ogz zbiF)7(X1I(`zD~!(ZzGI2acpNiQwcz51mY9uRYO35WPB#-FfG&HtsvRdUtf`&DCSk z+drQFd*tcW^y;pK-kBOGy8PPy2e<&wk2*zVZ1DkNiZi^PnmQ&rL2=hf6IxY6`g2~A zaF-`a&2W;RfLxzUVdbvt=X&M93qsvz28c*YB_~-^(zT)Oja$*HFUO7_yq0Und_Yii z@y~nbw*2MQ3g=oKn6u?#u2Lf!MT7#T9H2!Ps%s}6KDGHt zu-tW{Hyh)HX_{Z`b!_i?rjJbA;sBfg|LwrX!2R$I=-!czT?yOi3Yf@Vd-)?t*<~M-AH?TrY0x>v~Q4SOfFfK4HVr6t;E-^TlVFd&ilZrp6m)_P43bTJs zZvmGW&k7E+KTu2rvp-em0h6vS9)qw~hp<-xx3E_N>4&#$-~xv|x0@aV;|!MtEd%t2 zqAmlsqAmoLotIB{10A;r&jbbM0x>w3QTzilRBdi~Lt0ceXmn#zPIobCR$?nxd1g&j zO=4s*Rc%;UFG^QXbxmeALTzd}GDJczRyR*)Yc*~)a$