Skip to content

Commit

Permalink
feat: navigate explorer with keyboard (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
james-elicx authored Sep 9, 2023
1 parent 05b5869 commit ebe2228
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 45 deletions.
6 changes: 4 additions & 2 deletions app/bucket/[bucket]/[[...path]]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +21,9 @@ const Layout = async ({ params: { bucket, path }, children }: Props): Promise<JS
<Ctx bucketName={bucket} path={fullPath} />

<ObjectExplorerProvider>
<FilePreviewProvider bucketName={bucket}>{children}</FilePreviewProvider>
<FilePreviewProvider bucketName={bucket}>
<ExplorerEventsProvider>{children}</ExplorerEventsProvider>
</FilePreviewProvider>
</ObjectExplorerProvider>
</>
);
Expand Down
115 changes: 111 additions & 4 deletions components/object-explorer/explorer.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)[];
Expand All @@ -27,8 +27,13 @@ type Props = {
export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.Element => {
const parentRef = useRef<HTMLDivElement>(null);

const { isFilePreviewActive } = useFilePreview();

const {
objects = initialObjects,
selectedObjects,
addSelectedObject,
removeSelectedObject,
updateObjects,
tryFetchMoreObjects,
clearSelectedObjects,
Expand All @@ -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<HTMLDivElement>) => {
Expand Down Expand Up @@ -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<R2Object | string>) =>
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 (
<div className="flex flex-col">
<div className="table-header-group">
Expand Down
41 changes: 6 additions & 35 deletions components/object-explorer/object-row.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,23 @@
'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<R2Object | string>;
virtualRowSize: number;
};

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<HTMLDivElement>) => {
// 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 */}
Expand All @@ -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) => (
<div key={cell.id} className="table-cell" style={{ width: cell.column.getSize() }}>
Expand Down
81 changes: 81 additions & 0 deletions components/providers/explorer-events-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>, object: FileObject) => void;
handleDoubleClick: (object: Pick<FileObject, 'isDirectory' | 'path'>) => void;
handleContextMenu: (object: FileObject) => void;
};

const ExplorerEventsContext = createContext<IExplorerEventsContext>({
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<HTMLElement>, 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<FileObject, 'isDirectory' | 'path'>) => {
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 (
<ExplorerEventsContext.Provider
value={useMemo(
() => ({ handleMouseDown, handleDoubleClick, handleContextMenu }),
[handleMouseDown, handleDoubleClick, handleContextMenu],
)}
>
{children}
</ExplorerEventsContext.Provider>
);
};

export type { Props as ExplorerEventsProviderProps };
7 changes: 6 additions & 1 deletion components/providers/file-preview-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import { XCircle } from '../icons';

export type IFilePreviewContext = {
triggerFilePreview: (key: string) => void;
isFilePreviewActive: boolean;
};

const FilePreviewContext = createContext<IFilePreviewContext>({
triggerFilePreview: () => {},
isFilePreviewActive: false,
});

export const useFilePreview = () => useContext(FilePreviewContext);
Expand Down Expand Up @@ -104,7 +106,10 @@ export const FilePreviewProvider = ({ bucketName, children }: Props): JSX.Elemen

return (
<FilePreviewContext.Provider
value={useMemo(() => ({ triggerFilePreview }), [triggerFilePreview])}
value={useMemo(
() => ({ triggerFilePreview, isFilePreviewActive: !!previewKey }),
[triggerFilePreview, previewKey],
)}
>
{children}

Expand Down
1 change: 1 addition & 0 deletions components/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 3 additions & 3 deletions components/providers/object-explorer-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const ObjectExplorerContext = createContext<IObjectExplorerContext>({
objects: [],
updateObjects: () => {},
tryFetchMoreObjects: () => {},
selectedObjects: new Set<string>(),
selectedObjects: new Set(),
addSelectedObject: () => {},
removeSelectedObject: () => {},
clearSelectedObjects: () => {},
Expand All @@ -36,7 +36,7 @@ type Props = {
export const ObjectExplorerProvider = ({ children }: Props): JSX.Element => {
const { currentBucket, location } = useLocation();
const [objects, setObjects] = useState<ObjectItem[] | undefined>(undefined);
const [selectedObjects, setSelectedObjects] = useState<Set<string>>(new Set<string>());
const [selectedObjects, setSelectedObjects] = useState<Set<string>>(new Set());

const [, setIsFetchingMoreObjects] = useState<boolean>(false);
const isFetchingMoreObjectsRef = useRef<boolean>(false);
Expand Down Expand Up @@ -110,7 +110,7 @@ export const ObjectExplorerProvider = ({ children }: Props): JSX.Element => {
[selectedObjects],
);

const clearSelectedObjects = useCallback(() => setSelectedObjects(new Set<string>()), []);
const clearSelectedObjects = useCallback(() => setSelectedObjects(new Set()), []);

return (
<ObjectExplorerContext.Provider
Expand Down
2 changes: 2 additions & 0 deletions utils/file-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,5 @@ export const parseObject = (object: string | R2Object) => {
},
};
};

export type FileObject = ReturnType<typeof parseObject>;

0 comments on commit ebe2228

Please sign in to comment.