diff --git a/app/bucket/[bucket]/[[...path]]/layout.tsx b/app/bucket/[bucket]/[[...path]]/layout.tsx index 7d7fa43..df186d7 100644 --- a/app/bucket/[bucket]/[[...path]]/layout.tsx +++ b/app/bucket/[bucket]/[[...path]]/layout.tsx @@ -1,6 +1,6 @@ import { validateBucketName } from '@/utils/cf'; import { formatBucketName, formatFullPath } from '@/utils'; -import { ObjectExplorerProvider, FilePreviewProvider } from '@/components'; +import { ObjectExplorerProvider, FilePreviewProvider, ExplorerEventsProvider } from '@/components'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { Ctx } from './ctx'; @@ -21,7 +21,9 @@ const Layout = async ({ params: { bucket, path }, children }: Props): Promise - {children} + + {children} + ); diff --git a/components/object-explorer/explorer.tsx b/components/object-explorer/explorer.tsx index 4ba55f2..3cd132b 100644 --- a/components/object-explorer/explorer.tsx +++ b/components/object-explorer/explorer.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; -import type { ColumnDef, SortingState } from '@tanstack/react-table'; +import type { ColumnDef, Row, SortingState } from '@tanstack/react-table'; import { flexRender, getCoreRowModel, @@ -15,7 +15,7 @@ import { useOnClickOutside } from '@/utils/hooks'; import { alphanumeric } from '@/utils/compare-alphanumeric-patched'; import { getFileIcon, getSortIcon } from './file-icons'; import { ObjectRow } from './object-row'; -import { useObjectExplorer } from '../providers'; +import { useExplorerEvents, useFilePreview, useObjectExplorer } from '../providers'; type Props = { initialObjects: (R2Object | string)[]; @@ -27,8 +27,13 @@ type Props = { export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.Element => { const parentRef = useRef(null); + const { isFilePreviewActive } = useFilePreview(); + const { objects = initialObjects, + selectedObjects, + addSelectedObject, + removeSelectedObject, updateObjects, tryFetchMoreObjects, clearSelectedObjects, @@ -39,7 +44,8 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El [updateObjects, initialObjects, initialCursor], ); - useOnClickOutside(parentRef, clearSelectedObjects); + // disable if the file is currently being previewed so that we don't lose the focus. + useOnClickOutside(parentRef, clearSelectedObjects, isFilePreviewActive); const handleScroll = useCallback( (e: React.UIEvent) => { @@ -155,6 +161,107 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El const paddingBottom = virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0; + const { handleDoubleClick } = useExplorerEvents(); + + const handleKeyPress = useCallback( + (e: KeyboardEvent) => { + if (isFilePreviewActive) return; + + const getFirstSelected = () => selectedObjects.values().next().value as string; + const getLastSelected = () => [...selectedObjects.values()].reverse()[0] as string; + + const getRowPath = (row: Row) => + typeof row.original === 'string' ? row.original : row.original.key; + const getIndex = (key: string) => rows.findIndex((row) => key === getRowPath(row)); + + switch (e.key) { + case 'Enter': { + // trigger preview for selected objects. + if (selectedObjects.size === 1) { + const nextObj = selectedObjects.values().next().value as string; + handleDoubleClick({ + isDirectory: nextObj.endsWith('/'), + path: nextObj, + }); + } else if (selectedObjects.size > 1) { + // eslint-disable-next-line no-console + console.warn('Multiple objects selected. Preview not supported.'); + } + e.stopPropagation(); + e.preventDefault(); + break; + } + case 'ArrowDown': { + if (selectedObjects.size === 0) { + const first = rows[0]; + if (first) addSelectedObject(getRowPath(first), !e.shiftKey); + } else { + const firstSelected = getFirstSelected(); + const firstSelectedIdx = getIndex(firstSelected); + const lastSelected = getLastSelected(); + const lastSelectedIdx = getIndex(lastSelected); + + if (firstSelectedIdx === -1 || lastSelectedIdx === -1) { + // do nothing + } else if (firstSelectedIdx > lastSelectedIdx) { + removeSelectedObject(lastSelected); + } else { + const nextObj = rows[lastSelectedIdx + 1]; + if (nextObj) addSelectedObject(getRowPath(nextObj), !e.shiftKey); + } + } + e.stopPropagation(); + e.preventDefault(); + break; + } + case 'ArrowUp': { + if (selectedObjects.size === 0 && rows[0]) { + const last = rows[rows.length - 1]; + if (last) addSelectedObject(getRowPath(last), !e.shiftKey); + } else { + const firstSelected = getFirstSelected(); + const firstSelectedIdx = getIndex(firstSelected); + const lastSelected = getLastSelected(); + const lastSelectedIdx = getIndex(lastSelected); + + if (firstSelectedIdx === -1 || lastSelectedIdx === -1) { + // do nothing + } else if (firstSelectedIdx < lastSelectedIdx) { + removeSelectedObject(lastSelected); + } else { + const nextObj = rows[lastSelectedIdx - 1]; + if (nextObj) addSelectedObject(getRowPath(nextObj), !e.shiftKey); + } + } + e.stopPropagation(); + e.preventDefault(); + break; + } + case 'Escape': { + clearSelectedObjects(); + break; + } + default: + break; + } + }, + [ + addSelectedObject, + clearSelectedObjects, + handleDoubleClick, + isFilePreviewActive, + removeSelectedObject, + rows, + selectedObjects, + ], + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyPress); + + return () => document.removeEventListener('keydown', handleKeyPress); + }, [handleKeyPress]); + return (
diff --git a/components/object-explorer/object-row.tsx b/components/object-explorer/object-row.tsx index c15f3fc..889f310 100644 --- a/components/object-explorer/object-row.tsx +++ b/components/object-explorer/object-row.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useRouter } from 'next/navigation'; import { twMerge } from 'tailwind-merge'; import { parseObject } from '@/utils'; import type { Row } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; -import { useObjectExplorer, useFilePreview, useLocation } from '../providers'; +import { useObjectExplorer, useExplorerEvents } from '../providers'; type Props = { row: Row; @@ -13,40 +12,12 @@ type Props = { }; export const ObjectRow = ({ row, virtualRowSize }: Props): JSX.Element => { - const router = useRouter(); - const { currentBucket } = useLocation(); - - const { selectedObjects, addSelectedObject } = useObjectExplorer(); - const { triggerFilePreview } = useFilePreview(); + const { selectedObjects } = useObjectExplorer(); + const { handleMouseDown, handleDoubleClick, handleContextMenu } = useExplorerEvents(); const object = parseObject(row.original); const isSelected = selectedObjects.has(object.path); - const handleMouseDown = (e: React.MouseEvent) => { - // We only trigger selection on left click. - // `onMouseDown` is used instead of `onClick` so we can mark an object as selected on focusing instead of after the click completes. - if (e.button !== 0) return; - - if (e.ctrlKey) { - addSelectedObject(object.path); - } else { - addSelectedObject(object.path, true); - } - }; - - const handleDoubleClick = () => { - if (object.isDirectory) { - router.push(`/bucket/${currentBucket?.raw}/${object.path}`); - } else { - triggerFilePreview(object.path); - } - }; - - const handleContextMenu = () => { - addSelectedObject(object.path, true); - // TODO: Render a context menu. - }; - return ( <> {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} @@ -59,9 +30,9 @@ export const ObjectRow = ({ row, virtualRowSize }: Props): JSX.Element => { 'border-accent !bg-secondary dark:border-accent-dark dark:!bg-secondary-dark', )} style={{ height: virtualRowSize }} - onMouseDown={handleMouseDown} - onDoubleClick={handleDoubleClick} - onContextMenu={handleContextMenu} + onMouseDown={(e) => handleMouseDown(e, object)} + onDoubleClick={() => handleDoubleClick(object)} + onContextMenu={() => handleContextMenu(object)} > {row.getVisibleCells().map((cell) => (
diff --git a/components/providers/explorer-events-provider.tsx b/components/providers/explorer-events-provider.tsx new file mode 100644 index 0000000..90f383f --- /dev/null +++ b/components/providers/explorer-events-provider.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { createContext, useCallback, useContext, useMemo } from 'react'; +import type { FileObject } from '@/utils/file-object'; +import { useRouter } from 'next/navigation'; +import { useObjectExplorer } from './object-explorer-provider'; +import { useLocation } from './location-provider'; +import { useFilePreview } from './file-preview-provider'; + +export type IExplorerEventsContext = { + handleMouseDown: (e: React.MouseEvent, object: FileObject) => void; + handleDoubleClick: (object: Pick) => void; + handleContextMenu: (object: FileObject) => void; +}; + +const ExplorerEventsContext = createContext({ + handleMouseDown: () => {}, + handleDoubleClick: () => {}, + handleContextMenu: () => {}, +}); + +export const useExplorerEvents = () => useContext(ExplorerEventsContext); + +type Props = { + children: React.ReactNode; +}; + +export const ExplorerEventsProvider = ({ children }: Props): JSX.Element => { + const router = useRouter(); + const { currentBucket } = useLocation(); + + const { addSelectedObject } = useObjectExplorer(); + const { triggerFilePreview } = useFilePreview(); + + const handleMouseDown = useCallback( + (e: React.MouseEvent, object: FileObject) => { + // We only trigger selection on left click. + // `onMouseDown` is used instead of `onClick` so we can mark an object as selected on focusing instead of after the click completes. + if (e.button !== 0) return; + + if (e.ctrlKey) { + addSelectedObject(object.path); + } else { + addSelectedObject(object.path, true); + } + }, + [addSelectedObject], + ); + + const handleDoubleClick = useCallback( + (object: Pick) => { + if (object.isDirectory) { + router.push(`/bucket/${currentBucket?.raw}/${object.path}`); + } else { + triggerFilePreview(object.path); + } + }, + [triggerFilePreview, router, currentBucket], + ); + + const handleContextMenu = useCallback( + (object: FileObject) => { + addSelectedObject(object.path, true); + // TODO: Render a context menu. + }, + [addSelectedObject], + ); + + return ( + ({ handleMouseDown, handleDoubleClick, handleContextMenu }), + [handleMouseDown, handleDoubleClick, handleContextMenu], + )} + > + {children} + + ); +}; + +export type { Props as ExplorerEventsProviderProps }; diff --git a/components/providers/file-preview-provider.tsx b/components/providers/file-preview-provider.tsx index d935aca..8a9eef5 100644 --- a/components/providers/file-preview-provider.tsx +++ b/components/providers/file-preview-provider.tsx @@ -15,10 +15,12 @@ import { XCircle } from '../icons'; export type IFilePreviewContext = { triggerFilePreview: (key: string) => void; + isFilePreviewActive: boolean; }; const FilePreviewContext = createContext({ triggerFilePreview: () => {}, + isFilePreviewActive: false, }); export const useFilePreview = () => useContext(FilePreviewContext); @@ -104,7 +106,10 @@ export const FilePreviewProvider = ({ bucketName, children }: Props): JSX.Elemen return ( ({ triggerFilePreview }), [triggerFilePreview])} + value={useMemo( + () => ({ triggerFilePreview, isFilePreviewActive: !!previewKey }), + [triggerFilePreview, previewKey], + )} > {children} diff --git a/components/providers/index.ts b/components/providers/index.ts index 2204eca..fb51d27 100644 --- a/components/providers/index.ts +++ b/components/providers/index.ts @@ -3,3 +3,4 @@ export * from './location-provider'; export * from './file-preview-provider'; export * from './object-explorer-provider'; export * from './upload-files-provider'; +export * from './explorer-events-provider'; diff --git a/components/providers/object-explorer-provider.tsx b/components/providers/object-explorer-provider.tsx index 47e01fc..e5106e2 100644 --- a/components/providers/object-explorer-provider.tsx +++ b/components/providers/object-explorer-provider.tsx @@ -21,7 +21,7 @@ const ObjectExplorerContext = createContext({ objects: [], updateObjects: () => {}, tryFetchMoreObjects: () => {}, - selectedObjects: new Set(), + selectedObjects: new Set(), addSelectedObject: () => {}, removeSelectedObject: () => {}, clearSelectedObjects: () => {}, @@ -36,7 +36,7 @@ type Props = { export const ObjectExplorerProvider = ({ children }: Props): JSX.Element => { const { currentBucket, location } = useLocation(); const [objects, setObjects] = useState(undefined); - const [selectedObjects, setSelectedObjects] = useState>(new Set()); + const [selectedObjects, setSelectedObjects] = useState>(new Set()); const [, setIsFetchingMoreObjects] = useState(false); const isFetchingMoreObjectsRef = useRef(false); @@ -110,7 +110,7 @@ export const ObjectExplorerProvider = ({ children }: Props): JSX.Element => { [selectedObjects], ); - const clearSelectedObjects = useCallback(() => setSelectedObjects(new Set()), []); + const clearSelectedObjects = useCallback(() => setSelectedObjects(new Set()), []); return ( { }, }; }; + +export type FileObject = ReturnType;