Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: navigate explorer with keyboard #11

Merged
merged 1 commit into from
Sep 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>;