mono/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts
2026-03-21 20:18:25 +01:00

332 lines
14 KiB
TypeScript

import React, { useCallback } from 'react';
import { INode } from '@/modules/storage/types';
import { getMimeCategory } from '@/modules/storage/helpers';
export interface UseKeyboardNavigationProps {
viewMode: 'list' | 'thumbs' | 'tree';
itemCount: number;
focusIdx: number;
setFocusIdx: (idx: number) => void;
selected: INode[];
setSelected: React.Dispatch<React.SetStateAction<INode[]>>;
getNode: (idx: number) => INode | null;
canGoUp: boolean;
goUp: () => void;
updatePath: (path: string) => void;
openPreview: (node: INode) => void;
scrollItemIntoView: (idx: number) => void;
getGridCols: () => number;
currentGlob: string;
showFolders: boolean;
setTempGlob: (glob: string) => void;
setTempShowFolders: (val: boolean) => void;
setFilterDialogOpen: (open: boolean) => void;
setViewMode: (m: 'list' | 'thumbs' | 'tree') => void;
setDisplayMode: (m: 'simple' | 'advanced') => void;
cycleSort: () => void;
searchBufferRef: React.MutableRefObject<string>;
setSearchDisplay: (val: string) => void;
clearSelection: () => void;
}
export function useKeyboardNavigation({
viewMode,
itemCount,
focusIdx,
setFocusIdx,
selected,
setSelected,
getNode,
canGoUp,
goUp,
updatePath,
openPreview,
scrollItemIntoView,
getGridCols,
currentGlob,
showFolders,
setTempGlob,
setTempShowFolders,
setFilterDialogOpen,
setViewMode,
setDisplayMode,
cycleSort,
searchBufferRef,
setSearchDisplay,
clearSelection
}: UseKeyboardNavigationProps) {
const moveFocus = useCallback((next: number, shift: boolean, ctrl: boolean) => {
next = Math.max(0, Math.min(itemCount - 1, next));
setFocusIdx(next);
const node = getNode(next);
if (node) {
if (shift && selected.length > 0) {
const lastSelected = selected[selected.length - 1]; // Assume selected array maintains order
// Reconstruct sequential selection
// Getting indexOf requires knowing if it was shifted by canGoUp. This hook relies on parent handling that.
// We'll just append it or set it, but we need exact index of last selected.
// We delegate this to parent or infer it by iteration:
let lastIdx = -1;
for (let i = 0; i < itemCount; i++) {
const n = getNode(i);
if (n && n.path === lastSelected.path) {
lastIdx = i;
break;
}
}
if (lastIdx !== -1) {
const start = Math.min(lastIdx, next);
const end = Math.max(lastIdx, next);
const newSel: INode[] = [];
for (let i = start; i <= end; i++) {
const n = getNode(i);
if (n) newSel.push(n);
}
setSelected(ctrl ? Array.from(new Set([...selected, ...newSel])) : newSel);
} else {
setSelected(ctrl ? [...selected, node] : [node]);
}
} else if (ctrl) {
// Just move focus, don't change selection
} else {
setSelected([node]);
}
} else {
setSelected([]);
}
scrollItemIntoView(next);
}, [itemCount, setFocusIdx, getNode, selected, setSelected, scrollItemIntoView]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Only run if active element is the container or its descendants
// AND not if an input is focused.
const active = document.activeElement;
if (active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA') return;
// In tree mode, we want to let FileTree handle most of the arrow keys and selections
// so we don't accidentally synchronize the fallback grid's focusIdx.
// We only want to handle global hotkeys like toggle view mode.
if (viewMode === 'tree' && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', ' ', 'Backspace', 'Home', 'End'].includes(e.key)) {
return;
}
if (itemCount === 0) return;
const cols = getGridCols();
const ctrl = e.ctrlKey || e.metaKey;
switch (e.key) {
case '+': {
if (e.shiftKey) {
e.preventDefault();
setTempGlob(currentGlob);
setTempShowFolders(showFolders);
setFilterDialogOpen(true);
}
break;
}
case 'ArrowRight': {
e.preventDefault();
moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0, e.shiftKey, e.ctrlKey || e.metaKey);
break;
}
case 'ArrowLeft': {
e.preventDefault();
moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1, e.shiftKey, e.ctrlKey || e.metaKey);
break;
}
case 'ArrowDown': {
e.preventDefault();
if (searchBufferRef.current) {
const searchStr = searchBufferRef.current;
let nextIdx = -1;
for (let i = 1; i < itemCount && nextIdx === -1; i++) {
const checkIdx = (focusIdx + i) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().startsWith(searchStr)) nextIdx = checkIdx;
}
for (let i = 1; i < itemCount && nextIdx === -1; i++) {
const checkIdx = (focusIdx + i) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().includes(searchStr)) nextIdx = checkIdx;
}
if (nextIdx !== -1) moveFocus(nextIdx, e.shiftKey, e.ctrlKey || e.metaKey);
} else {
moveFocus(focusIdx + cols, e.shiftKey, e.ctrlKey || e.metaKey);
}
break;
}
case 'ArrowUp': {
e.preventDefault();
if (searchBufferRef.current) {
const searchStr = searchBufferRef.current;
let prevIdx = -1;
for (let i = 1; i < itemCount && prevIdx === -1; i++) {
const checkIdx = (focusIdx - i + itemCount) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().startsWith(searchStr)) prevIdx = checkIdx;
}
for (let i = 1; i < itemCount && prevIdx === -1; i++) {
const checkIdx = (focusIdx - i + itemCount) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().includes(searchStr)) prevIdx = checkIdx;
}
if (prevIdx !== -1) moveFocus(prevIdx, e.shiftKey, e.ctrlKey || e.metaKey);
} else {
moveFocus(focusIdx - cols, e.shiftKey, e.ctrlKey || e.metaKey);
}
break;
}
case 'Enter': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
if (!node) { goUp(); break; }
const cat = getMimeCategory(node);
if (cat === 'dir') updatePath(node.path || node.name);
else openPreview(node);
break;
}
case ' ': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
if (!node) break;
setSelected(prev => prev.some(n => n.path === node.path) ? prev.filter(n => n.path !== node.path) : [...prev, node]);
break;
}
case 'Backspace': {
e.preventDefault();
if (searchBufferRef.current.length > 0) {
searchBufferRef.current = searchBufferRef.current.slice(0, -1);
setSearchDisplay(searchBufferRef.current);
if (searchBufferRef.current.length > 0) {
const searchStr = searchBufferRef.current;
let foundIdx = -1;
const start = Math.max(0, focusIdx);
for (let i = 0; i < itemCount && foundIdx === -1; i++) {
const checkIdx = (start + i) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().startsWith(searchStr)) foundIdx = checkIdx;
}
for (let i = 0; i < itemCount && foundIdx === -1; i++) {
const checkIdx = (start + i) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().includes(searchStr)) foundIdx = checkIdx;
}
if (foundIdx !== -1) moveFocus(foundIdx, false, false);
}
} else {
goUp();
}
break;
}
case 'Home': {
e.preventDefault();
moveFocus(0, e.shiftKey, e.ctrlKey || e.metaKey);
break;
}
case 'End': {
e.preventDefault();
moveFocus(itemCount - 1, e.shiftKey, e.ctrlKey || e.metaKey);
break;
}
case 'Escape': {
e.preventDefault();
if (searchBufferRef.current !== '') {
searchBufferRef.current = '';
setSearchDisplay('');
} else {
clearSelection();
}
break;
}
case 'F1': {
if (e.altKey) {
e.preventDefault();
document.getElementById('fb-mount-trigger')?.click();
}
break;
}
case '1': {
if (e.altKey) {
e.preventDefault();
setViewMode('tree');
setDisplayMode('simple');
}
break;
}
case '2': {
if (e.altKey) {
e.preventDefault();
setViewMode('list');
setDisplayMode('simple');
}
break;
}
case '3': {
if (e.altKey) {
e.preventDefault();
setViewMode('thumbs');
setDisplayMode('simple');
}
break;
}
case 's': {
if (e.altKey) cycleSort();
break;
}
case 'a': {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const allNodes: INode[] = [];
for (let i = 0; i < itemCount; i++) {
const n = getNode(i);
if (n) allNodes.push(n);
}
setSelected(allNodes);
}
break;
}
case 'Meta':
case 'Control':
case 'Shift':
case 'Alt':
break;
default: {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key.length === 1) {
const char = e.key.toLowerCase();
searchBufferRef.current += char;
setSearchDisplay(searchBufferRef.current);
const searchStr = searchBufferRef.current;
let foundIdx = -1;
const start = Math.max(0, focusIdx);
for (let i = 0; i < itemCount && foundIdx === -1; i++) {
const checkIdx = (start + i) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().startsWith(searchStr)) foundIdx = checkIdx;
}
for (let i = 0; i < itemCount && foundIdx === -1; i++) {
const checkIdx = (start + i) % itemCount;
const node = getNode(checkIdx);
if (node && node.name.toLowerCase().includes(searchStr)) foundIdx = checkIdx;
}
if (foundIdx !== -1) moveFocus(foundIdx, false, false);
}
break;
}
}
}, [
viewMode, itemCount, getGridCols, currentGlob, showFolders, setTempGlob, setTempShowFolders,
setFilterDialogOpen, moveFocus, focusIdx, searchBufferRef, getNode, setSearchDisplay, goUp,
updatePath, openPreview, selected, setSelected, clearSelection, setViewMode, setDisplayMode, cycleSort
]);
return { handleKeyDown, moveFocus };
}