diff --git a/apps/desktop/src-tauri/src/menu.rs b/apps/desktop/src-tauri/src/menu.rs index f7467c045..0f53b2d91 100644 --- a/apps/desktop/src-tauri/src/menu.rs +++ b/apps/desktop/src-tauri/src/menu.rs @@ -74,6 +74,7 @@ fn custom_menu_bar() -> Menu { let edit_menu = Menu::new() .add_native_item(MenuItem::Separator) .add_native_item(MenuItem::Copy) + .add_native_item(MenuItem::Cut) .add_native_item(MenuItem::Paste) .add_native_item(MenuItem::Redo) .add_native_item(MenuItem::Undo) diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx index 3dfd6dd2f..55d8f8afc 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx @@ -1,7 +1,15 @@ import { Image, Package, Trash, TrashSimple } from '@phosphor-icons/react'; import { libraryClient, useLibraryMutation } from '@sd/client'; -import { ContextMenu, dialogManager, ModifierKeys, toast } from '@sd/ui'; +import { + ContextMenu, + dialogManager, + keySymbols, + ModifierKeys, + modifierSymbols, + toast +} from '@sd/ui'; import { Menu } from '~/components/Menu'; +import { useOperatingSystem } from '~/hooks'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; import { useQuickRescan } from '~/hooks/useQuickRescan'; import { isNonEmpty } from '~/util'; @@ -24,8 +32,6 @@ export const Delete = new ConditionalItem({ return { selectedFilePaths, selectedEphemeralPaths }; }, Component: ({ selectedFilePaths, selectedEphemeralPaths }) => { - const keybind = useKeybindFactory(); - const rescan = useQuickRescan(); const dirCount = @@ -55,7 +61,6 @@ export const Delete = new ConditionalItem({ icon={Trash} label="Delete" variant="danger" - keybind={keybind([ModifierKeys.Control], ['Delete'])} onClick={() => dialogManager.create((dp) => ( ( blur(); break; } - case 'Escape': { e.stopPropagation(); reset(); blur(); break; } - case 'z': { if (os === 'macOS' ? e.metaKey : e.ctrlKey) { reset(); @@ -108,9 +105,8 @@ export const RenameTextBox = forwardRef( return `...${name.slice(-8)}`; }, [name]); - useKey(['F2', 'Enter'], (e) => { + useShortcut('renameObject', (e) => { e.preventDefault(); - if (os === 'windows' && e.key === 'Enter') return; if (allowRename) blur(); else if (!disabled) setAllowRename(true); }); diff --git a/interface/app/$libraryId/Explorer/OptionsPanel.tsx b/interface/app/$libraryId/Explorer/OptionsPanel.tsx index 21447cb0f..ddfcad731 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel.tsx +++ b/interface/app/$libraryId/Explorer/OptionsPanel.tsx @@ -8,7 +8,6 @@ import { createOrdering, getOrderingDirection, orderingKey, useExplorerStore } f const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`; export default () => { - const explorerStore = useExplorerStore(); const explorer = useExplorerContext(); const layoutStore = useExplorerLayoutStore(); @@ -145,7 +144,6 @@ export default () => { name="showHiddenFiles" onCheckedChange={(value) => { if (typeof value !== 'boolean') return; - explorer.settingsStore.showHiddenFiles = value; }} /> diff --git a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx index d23106fcc..4d90b9b88 100644 --- a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx +++ b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx @@ -22,17 +22,8 @@ import { useRspcLibraryContext, useZodForm } from '@sd/client'; -import { - dialogManager, - DropdownMenu, - Form, - ModifierKeys, - toast, - ToastMessage, - Tooltip, - z -} from '@sd/ui'; -import { useIsDark, useKeybind, useOperatingSystem } from '~/hooks'; +import { dialogManager, DropdownMenu, Form, toast, ToastMessage, Tooltip, z } from '@sd/ui'; +import { useIsDark, useKeybind, useOperatingSystem, useShortcut } from '~/hooks'; import { usePlatform } from '~/util/Platform'; import { useExplorerContext } from '../Context'; @@ -43,7 +34,6 @@ import ExplorerContextMenu, { SharedItems } from '../ContextMenu'; import { Conditional } from '../ContextMenu/ConditionalItem'; -import DeleteDialog from '../FilePath/DeleteDialog'; import { FileThumb } from '../FilePath/Thumb'; import { SingleItemMetadata } from '../Inspector'; import { getQuickPreviewStore, useQuickPreviewStore } from './store'; @@ -63,11 +53,10 @@ const useQuickPreviewContext = () => { }; export const QuickPreview = () => { - const os = useOperatingSystem(); const rspc = useRspcLibraryContext(); const isDark = useIsDark(); const { library } = useLibraryContext(); - const { openFilePaths, revealItems, openEphemeralFiles } = usePlatform(); + const { openFilePaths, openEphemeralFiles } = usePlatform(); const explorer = useExplorerContext(); const { open, itemIndex } = useQuickPreviewStore(); @@ -78,6 +67,7 @@ export const QuickPreview = () => { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [newName, setNewName] = useState(null); + const os = useOperatingSystem(); const items = useMemo( () => (open ? [...explorer.selectedItems] : []), @@ -136,7 +126,8 @@ export const QuickPreview = () => { }, [item, open]); // Toggle quick preview - useKeybind(['space'], (e) => { + useShortcut('toggleQuickPreview', (e) => { + console.log(e.key); if (isRenaming) return; e.preventDefault(); @@ -144,21 +135,17 @@ export const QuickPreview = () => { getQuickPreviewStore().open = !open; }); - useKeybind('Escape', (e) => open && e.stopPropagation()); - // Move between items - useKeybind([['left'], ['right']], (e) => { + useShortcut('quickPreviewMoveBetweenItems', (e) => { if (isContextMenuOpen || isRenaming) return; changeCurrentItem(e.key === 'ArrowLeft' ? itemIndex - 1 : itemIndex + 1); }); // Toggle metadata - useKeybind([os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control, 'i'], () => - setShowMetadata(!showMetadata) - ); + useShortcut('toggleMetaData', () => setShowMetadata(!showMetadata)); // Open file - useKeybind([os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control, 'o'], () => { + useShortcut('quickPreviewOpenNative', () => { if (!item || !openFilePaths || !openEphemeralFiles) return; try { @@ -179,66 +166,6 @@ export const QuickPreview = () => { } }); - // Reveal in native explorer - useKeybind([os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control, 'y'], () => { - if (!item || !revealItems) return; - - try { - const toReveal = []; - if (item.type === 'Location') { - toReveal.push({ Location: { id: item.item.id } }); - } else if (item.type === 'NonIndexedPath') { - toReveal.push({ Ephemeral: { path: item.item.path } }); - } else { - const filePath = getIndexedItemFilePath(item); - if (!filePath) throw 'No file path found'; - toReveal.push({ FilePath: { id: filePath.id } }); - } - - revealItems(library.uuid, toReveal); - } catch (error) { - toast.error({ - title: 'Failed to reveal', - body: `Couldn't reveal file, due to an error: ${error}` - }); - } - }); - - // Open delete dialog - useKeybind([os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control, 'backspace'], () => { - if (!item) return; - - const path = getIndexedItemFilePath(item); - - if (path != null && path.location_id !== null) { - return dialogManager.create((dp) => ( - - )); - } - - const ephemeralFile = getEphemeralPath(item); - if (ephemeralFile != null) { - return dialogManager.create((dp) => ( - - )); - } - }); - if (!item) return null; const { kind, ...itemData } = getExplorerItemData(item); diff --git a/interface/app/$libraryId/Explorer/View/GridList.tsx b/interface/app/$libraryId/Explorer/View/GridList.tsx index 7fcefd21b..c62a2e07f 100644 --- a/interface/app/$libraryId/Explorer/View/GridList.tsx +++ b/interface/app/$libraryId/Explorer/View/GridList.tsx @@ -10,9 +10,8 @@ import { type ReactNode } from 'react'; import Selecto from 'react-selecto'; -import { useKey } from 'rooks'; import { type ExplorerItem } from '@sd/client'; -import { useMouseNavigate, useOperatingSystem } from '~/hooks'; +import { useMouseNavigate, useOperatingSystem, useShortcut } from '~/hooks'; import { useExplorerContext } from '../Context'; import { getQuickPreviewStore } from '../QuickPreview/store'; @@ -84,7 +83,7 @@ const GridListItem = (props: { return (
{ activeItem.current = null; }, [explorer.selectedItems]); - useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'Escape'], (e) => { + useShortcut('explorerEscape', () => { if (!explorerView.selectable) return; + explorer.resetSelectedItems([]); + selecto.current?.setSelectedTargets([]); + }); - if (e.key === 'Escape') { - explorer.resetSelectedItems([]); - selecto.current?.setSelectedTargets([]); - return; - } - - if (e.key === 'ArrowDown' && explorer.selectedItems.size === 0) { - const item = grid.getItem(0); - if (!item?.data) return; - - const id = uniqueId(item.data); - - const selectedItemDom = document.querySelector( - `[data-selectable-id="${realOS === 'windows' ? id.replaceAll('\\', '\\\\') : id}"]` - ); - - if (selectedItemDom) { - explorer.resetSelectedItems([item.data]); - selecto.current?.setSelectedTargets([selectedItemDom as HTMLElement]); - activeItem.current = item.data; - } - return; - } + const keyboardHandler = (e: KeyboardEvent, newIndex: number) => { + if (!explorerView.selectable) return; if (explorer.selectedItems.size > 0) e.preventDefault(); @@ -283,24 +264,9 @@ export default ({ children }: { children: RenderItem }) => { if (!gridItem) return; const currentIndex = gridItem.index; - let newIndex = currentIndex; - - switch (e.key) { - case 'ArrowUp': - newIndex -= grid.columnCount; - break; - case 'ArrowDown': - newIndex += grid.columnCount; - break; - case 'ArrowRight': - newIndex += 1; - break; - case 'ArrowLeft': - newIndex -= 1; - break; - } - - const newSelectedItem = grid.getItem(newIndex); + let updatedIndex = currentIndex; + updatedIndex = newIndex; + const newSelectedItem = grid.getItem(updatedIndex); if (!newSelectedItem?.data) return; if (!explorer.allowMultiSelect) explorer.resetSelectedItems([newSelectedItem.data]); else { @@ -366,6 +332,76 @@ export default ({ children }: { children: RenderItem }) => { }); } } + }; + const getGridItemHandler = (key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') => { + const lastItem = activeItem.current; + if (!lastItem) return; + + const lastItemIndex = explorer.items?.findIndex((item) => item === lastItem); + if (lastItemIndex === undefined || lastItemIndex === -1) return; + + const gridItem = grid.getItem(lastItemIndex); + if (!gridItem) return; + + let newIndex = gridItem.index; + + switch (key) { + case 'ArrowUp': + newIndex -= grid.columnCount; + break; + case 'ArrowDown': + newIndex += grid.columnCount; + break; + case 'ArrowLeft': + newIndex -= 1; + break; + case 'ArrowRight': + newIndex += 1; + break; + } + return newIndex; + }; + + useShortcut('explorerDown', (e) => { + if (!explorerView.selectable) return; + if (explorer.selectedItems.size === 0) { + const item = grid.getItem(0); + if (!item?.data) return; + + const id = uniqueId(item.data); + + const selectedItemDom = document.querySelector( + `[data-selectable-id="${realOS === 'windows' ? id.replaceAll('\\', '\\\\') : id}"]` + ); + + if (selectedItemDom) { + explorer.resetSelectedItems([item.data]); + selecto.current?.setSelectedTargets([selectedItemDom as HTMLElement]); + activeItem.current = item.data; + } + } else { + const newIndex = getGridItemHandler('ArrowDown'); + if (newIndex === undefined) return; + keyboardHandler(e, newIndex); + } + }); + + useShortcut('explorerUp', (e) => { + const newIndex = getGridItemHandler('ArrowUp'); + if (newIndex === undefined) return; + keyboardHandler(e, newIndex); + }); + + useShortcut('explorerLeft', (e) => { + const newIndex = getGridItemHandler('ArrowLeft'); + if (newIndex === undefined) return; + keyboardHandler(e, newIndex); + }); + + useShortcut('explorerRight', (e) => { + const newIndex = getGridItemHandler('ArrowRight'); + if (newIndex === undefined) return; + keyboardHandler(e, newIndex); }); return ( diff --git a/interface/app/$libraryId/Explorer/View/ListView/index.tsx b/interface/app/$libraryId/Explorer/View/ListView/index.tsx index 50a1bd3c6..843120503 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/index.tsx @@ -9,11 +9,11 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import BasicSticky from 'react-sticky-el'; -import { useKey, useMutationObserver, useWindowEventListener } from 'rooks'; +import { useMutationObserver, useWindowEventListener } from 'rooks'; import useResizeObserver from 'use-resize-observer'; import { getItemFilePath, type ExplorerItem } from '@sd/client'; import { ContextMenu, Tooltip } from '@sd/ui'; -import { useIsTextTruncated, useMouseNavigate } from '~/hooks'; +import { useIsTextTruncated, useMouseNavigate, useShortcut } from '~/hooks'; import { isNonEmptyObject } from '~/util'; import { useLayoutContext } from '../../../Layout/Context'; @@ -51,7 +51,7 @@ const ListViewItem = memo((props: ListViewItemProps) => { return ( {props.row.getVisibleCells().map((cell) => ( @@ -607,15 +607,14 @@ export default () => { }; }, [sized, isLeftMouseDown]); - // Handle key selection - useKey(['ArrowUp', 'ArrowDown', 'Escape'], (e) => { + const keyboardHandler = (e: KeyboardEvent, direction: 'ArrowDown' | 'ArrowUp') => { if (!explorerView.selectable) return; e.preventDefault(); const range = getRangeByIndex(ranges.length - 1); - if (e.key === 'ArrowDown' && explorer.selectedItems.size === 0) { + if (explorer.selectedItems.size === 0) { const item = rows[0]?.original; if (item) { explorer.addSelectedItem(item); @@ -626,13 +625,7 @@ export default () => { if (!range) return; - if (e.key === 'Escape') { - explorer.resetSelectedItems([]); - setRanges([]); - return; - } - - const keyDirection = e.key === 'ArrowDown' ? 'down' : 'up'; + const keyDirection = direction === 'ArrowDown' ? 'down' : 'up'; const nextRow = rows[range.end.index + (keyDirection === 'up' ? -1 : 1)]; @@ -766,6 +759,20 @@ export default () => { } else explorer.resetSelectedItems([item]); scrollToRow(nextRow); + }; + + useShortcut('explorerEscape', () => { + explorer.resetSelectedItems([]); + setRanges([]); + return; + }); + + useShortcut('explorerUp', (e) => { + keyboardHandler(e, 'ArrowUp'); + }); + + useShortcut('explorerDown', (e) => { + keyboardHandler(e, 'ArrowDown'); }); // Reset resizing cursor @@ -1002,7 +1009,7 @@ export default () => { return (
{ }} > {selectedPrior && ( -
+
)}
diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 5406c79b3..141aece28 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -10,11 +10,11 @@ import { type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { useKey, useKeys } from 'rooks'; +import { useKeys } from 'rooks'; import { ExplorerLayout, getItemObject, type Object } from '@sd/client'; import { dialogManager, ModifierKeys } from '@sd/ui'; import { Loader } from '~/components'; -import { useKeyCopyCutPaste, useKeyMatcher, useOperatingSystem } from '~/hooks'; +import { useKeyCopyCutPaste, useOperatingSystem, useShortcut } from '~/hooks'; import { isNonEmpty } from '~/util'; import CreateDialog from '../../settings/library/tags/CreateDialog'; @@ -61,13 +61,10 @@ export default memo( const explorer = useExplorerContext(); const quickPreview = useQuickPreviewContext(); const quickPreviewStore = useQuickPreviewStore(); - const os = useOperatingSystem(); const { doubleClick } = useViewItemDoubleClick(); const { layoutMode } = explorer.useSettingsSnapshot(); - const metaCtrlKey = useKeyMatcher('Meta').key; - const ref = useRef(null); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); @@ -103,16 +100,10 @@ export default memo( explorer.settingsStore.layoutMode = layout ?? 'grid'; }, [layoutMode, explorer.layouts, explorer.settingsStore]); - useKey(['Enter'], (e) => { + useShortcut('openObject', (e) => { e.stopPropagation(); - if (os === 'windows' && !isRenaming) { - doubleClick(); - } - }); - - useKeys([metaCtrlKey, 'KeyO'], (e) => { - e.stopPropagation(); - if (os === 'windows') return; + e.preventDefault(); + if (quickPreviewStore.open || isRenaming) return; doubleClick(); }); @@ -233,26 +224,13 @@ const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => { [os, explorer.selectedItems] ); - const handleExplorerShortcut = useCallback( - (event: KeyboardEvent) => { - if ( - event.key.toUpperCase() !== 'I' || - !event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control) - ) - return; - - getExplorerStore().showInspector = !getExplorerStore().showInspector; - }, - [os] - ); - useEffect(() => { - const handlers = [handleNewTag, handleExplorerShortcut]; + const handlers = [handleNewTag]; const handler = (event: KeyboardEvent) => { if (event.repeat || disabled) return; for (const handler of handlers) handler(event); }; document.body.addEventListener('keydown', handler); return () => document.body.removeEventListener('keydown', handler); - }, [disabled, handleNewTag, handleExplorerShortcut]); + }, [disabled, handleNewTag]); }; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 2abc38856..335176ea8 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -1,8 +1,7 @@ import { FolderNotchOpen } from '@phosphor-icons/react'; import { CSSProperties, type PropsWithChildren, type ReactNode } from 'react'; -import { useKeys } from 'rooks'; import { getExplorerLayoutStore, useExplorerLayoutStore, useLibrarySubscription } from '@sd/client'; -import { useKeysMatcher, useOperatingSystem } from '~/hooks'; +import { useShortcut } from '~/hooks'; import { TOP_BAR_HEIGHT } from '../TopBar'; import { useExplorerContext } from './Context'; @@ -10,7 +9,7 @@ import ContextMenu from './ContextMenu'; import DismissibleNotice from './DismissibleNotice'; import { Inspector, INSPECTOR_WIDTH } from './Inspector'; import ExplorerContextMenu from './ParentContextMenu'; -import { useExplorerStore } from './store'; +import { getExplorerStore, useExplorerStore } from './store'; import { useKeyRevealFinder } from './useKeyRevealFinder'; import View, { EmptyNotice, ExplorerViewProps } from './View'; import { ExplorerPath, PATH_BAR_HEIGHT } from './View/ExplorerPath'; @@ -28,10 +27,6 @@ export default function Explorer(props: PropsWithChildren) { const explorerStore = useExplorerStore(); const explorer = useExplorerContext(); const layoutStore = useExplorerLayoutStore(); - const shortcuts = useKeysMatcher(['Meta', 'Shift', 'Alt']); - const os = useOperatingSystem(); - const hiddenFilesShortcut = - os === 'macOS' ? [shortcuts.Meta.key, 'Shift', '.'] : [shortcuts.Meta.key, 'KeyH']; const showPathBar = explorer.showPathBar && layoutStore.showPathBar; @@ -48,12 +43,17 @@ export default function Explorer(props: PropsWithChildren) { } }); - useKeys([shortcuts.Alt.key, shortcuts.Meta.key, 'KeyP'], (e) => { + useShortcut('showPathBar', (e) => { e.stopPropagation(); getExplorerLayoutStore().showPathBar = !layoutStore.showPathBar; }); - useKeys(hiddenFilesShortcut, (e) => { + useShortcut('showInspector', (e) => { + e.stopPropagation(); + getExplorerStore().showInspector = !explorerStore.showInspector; + }); + + useShortcut('showHiddenFiles', (e) => { e.stopPropagation(); explorer.settingsStore.showHiddenFiles = !explorer.settingsStore.showHiddenFiles; }); diff --git a/interface/app/$libraryId/Explorer/useKeyRevealFinder.ts b/interface/app/$libraryId/Explorer/useKeyRevealFinder.ts index c5194ae86..bc074ff6b 100644 --- a/interface/app/$libraryId/Explorer/useKeyRevealFinder.ts +++ b/interface/app/$libraryId/Explorer/useKeyRevealFinder.ts @@ -1,14 +1,12 @@ import { useMemo } from 'react'; -import { useKeys } from 'rooks'; import { useLibraryContext } from '@sd/client'; import { useExplorerContext } from '~/app/$libraryId/Explorer/Context'; -import { useKeysMatcher } from '~/hooks'; +import { useShortcut } from '~/hooks'; import { usePlatform, type Platform } from '~/util/Platform'; export const useKeyRevealFinder = () => { const explorer = useExplorerContext(); const { revealItems } = usePlatform(); - const shortcuts = useKeysMatcher(['Meta']); const { library } = useLibraryContext(); const items = useMemo(() => { @@ -55,7 +53,7 @@ export const useKeyRevealFinder = () => { return array; }, [explorer.selectedItems]); - useKeys([shortcuts.Meta.key, 'KeyY'], (e) => { + useShortcut('revealNative', (e) => { e.stopPropagation(); if (!revealItems) return; revealItems(library.uuid, items); diff --git a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx index 89724abce..46ceb2195 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx @@ -1,9 +1,8 @@ import { ArrowsClockwise, Planet } from '@phosphor-icons/react'; import { useNavigate } from 'react-router'; -import { useKeys } from 'rooks'; import { LibraryContextProvider, useClientContext, useFeatureFlag } from '@sd/client'; import { Tooltip } from '@sd/ui'; -import { useKeysMatcher } from '~/hooks'; +import { useKeysMatcher, useShortcut } from '~/hooks'; import { EphemeralSection } from './EphemeralSection'; import Icon from './Icon'; @@ -13,9 +12,9 @@ import SidebarLink from './Link'; export default () => { const { library } = useClientContext(); const navigate = useNavigate(); - const shortcuts = useKeysMatcher(['Meta', 'Shift']); + const symbols = useKeysMatcher(['Meta', 'Shift']); - useKeys([shortcuts.Meta.key, 'Shift', 'KeyO'], (e) => { + useShortcut('navToOverview', (e) => { e.stopPropagation(); navigate('overview'); }); @@ -26,7 +25,7 @@ export default () => { diff --git a/interface/app/$libraryId/Layout/Sidebar/Footer.tsx b/interface/app/$libraryId/Layout/Sidebar/Footer.tsx index 44c9c21be..88f3577ac 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Footer.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Footer.tsx @@ -1,9 +1,8 @@ import { Gear } from '@phosphor-icons/react'; import { useNavigate } from 'react-router'; -import { useKeys } from 'rooks'; import { JobManagerContextProvider, useClientContext, useDebugState } from '@sd/client'; import { Button, ButtonLink, Popover, Tooltip, usePopover } from '@sd/ui'; -import { useKeysMatcher } from '~/hooks'; +import { useKeysMatcher, useShortcut } from '~/hooks'; import { usePlatform } from '~/util/Platform'; import DebugPopover from './DebugPopover'; @@ -14,9 +13,9 @@ export default () => { const { library } = useClientContext(); const debugState = useDebugState(); const navigate = useNavigate(); - const shortcuts = useKeysMatcher(['Meta', 'Shift']); + const symbols = useKeysMatcher(['Meta', 'Shift']); - useKeys([shortcuts.Meta.key, 'Shift', 'KeyT'], (e) => { + useShortcut('navToSettings', (e) => { e.stopPropagation(); navigate('settings/client/general'); }); @@ -50,7 +49,7 @@ export default () => { @@ -58,7 +57,7 @@ export default () => { { diff --git a/interface/app/$libraryId/TopBar/NavigationButtons.tsx b/interface/app/$libraryId/TopBar/NavigationButtons.tsx index f3b9c49ea..eb13b5f75 100644 --- a/interface/app/$libraryId/TopBar/NavigationButtons.tsx +++ b/interface/app/$libraryId/TopBar/NavigationButtons.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, ArrowRight } from '@phosphor-icons/react'; import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { Tooltip } from '@sd/ui'; -import { useKeybind, useKeyMatcher, useOperatingSystem, useSearchStore } from '~/hooks'; +import { useKeyMatcher, useOperatingSystem, useSearchStore, useShortcut } from '~/hooks'; import TopBarButton from './TopBarButton'; @@ -11,13 +11,13 @@ export const NavigationButtons = () => { const { isFocused } = useSearchStore(); const idx = history.state.idx as number; const os = useOperatingSystem(); - const { icon, key } = useKeyMatcher('Meta'); + const { icon } = useKeyMatcher('Meta'); - useKeybind([key, '['], () => { + useShortcut('navBackwardHistory', () => { if (idx === 0 || isFocused) return; navigate(-1); }); - useKeybind([key, ']'], () => { + useShortcut('navForwardHistory', () => { if (idx === history.length - 1 || isFocused) return; navigate(1); }); diff --git a/interface/app/$libraryId/settings/client/keybindings.tsx b/interface/app/$libraryId/settings/client/keybindings.tsx index 3477d8a80..af471e264 100644 --- a/interface/app/$libraryId/settings/client/keybindings.tsx +++ b/interface/app/$libraryId/settings/client/keybindings.tsx @@ -186,6 +186,50 @@ const shortcutCategories: Record = { } } }, + { + action: 'Copy selected item(s)', + keys: { + macOS: { + value: [modifierSymbols.Meta.macOS, 'C'] + }, + all: { + value: [modifierSymbols.Control.Other, 'C'] + } + } + }, + { + action: 'Cut selected item(s)', + keys: { + macOS: { + value: [modifierSymbols.Meta.macOS, 'X'] + }, + all: { + value: [modifierSymbols.Control.Other, 'X'] + } + } + }, + { + action: 'Paste selected item(s)', + keys: { + macOS: { + value: [modifierSymbols.Meta.macOS, 'V'] + }, + all: { + value: [modifierSymbols.Control.Other, 'V'] + } + } + }, + { + action: 'Duplicate selected item(s)', + keys: { + macOS: { + value: [modifierSymbols.Meta.macOS, 'D'] + }, + all: { + value: [modifierSymbols.Control.Other, 'D'] + } + } + }, { action: 'Reveal in Explorer/Finder', keys: { @@ -197,6 +241,17 @@ const shortcutCategories: Record = { } } }, + { + action: 'Rescan', + keys: { + macOS: { + value: [modifierSymbols.Meta.macOS, 'R'] + }, + all: { + value: [modifierSymbols.Control.Other, 'R'] + } + } + }, { action: 'Rename file or folder', keys: { diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index 7d36cd3f4..2b7e5a9ce 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -14,6 +14,7 @@ export * from './useKeybind'; export * from './useOperatingSystem'; export * from './useScrolled'; export * from './useSearchStore'; +export * from './useShortcut'; export * from './useShowControls'; export * from './useSpacedropState'; export * from './useTheme'; diff --git a/interface/hooks/useKeyCopyCutPaste.ts b/interface/hooks/useKeyCopyCutPaste.ts index 68c558cc2..6a6558082 100644 --- a/interface/hooks/useKeyCopyCutPaste.ts +++ b/interface/hooks/useKeyCopyCutPaste.ts @@ -1,4 +1,3 @@ -import { useKeys } from 'rooks'; import { useItemsAsEphemeralPaths, useItemsAsFilePaths, useLibraryMutation } from '@sd/client'; import { toast } from '@sd/ui'; import { useExplorerContext } from '~/app/$libraryId/Explorer/Context'; @@ -6,13 +5,12 @@ import { getExplorerStore, useExplorerStore } from '~/app/$libraryId/Explorer/st import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util'; import { isNonEmpty } from '~/util'; -import { useKeyMatcher } from './useKeyMatcher'; +import { useShortcut } from './useShortcut'; export const useKeyCopyCutPaste = () => { const { cutCopyState } = useExplorerStore(); const [{ path }] = useExplorerSearchParams(); - const metaCtrlKey = useKeyMatcher('Meta').key; const copyFiles = useLibraryMutation('files.copyFiles'); const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles'); const cutFiles = useLibraryMutation('files.cutFiles'); @@ -25,7 +23,7 @@ export const useKeyCopyCutPaste = () => { const selectedEphemeralPaths = useItemsAsEphemeralPaths(Array.from(explorer.selectedItems)); const indexedArgs = - parent?.type === 'Location' && !isNonEmpty(selectedFilePaths) + parent?.type === 'Location' ? { sourceLocationId: parent.location.id, sourcePathIds: selectedFilePaths.map((p) => p.id) @@ -33,35 +31,54 @@ export const useKeyCopyCutPaste = () => { : undefined; const ephemeralArgs = - parent?.type === 'Ephemeral' && !isNonEmpty(selectedEphemeralPaths) + parent?.type === 'Ephemeral' ? { sourcePaths: selectedEphemeralPaths.map((p) => p.path) } : undefined; - useKeys([metaCtrlKey, 'KeyC'], (e) => { + useShortcut('copyObject', (e) => { e.stopPropagation(); if (explorer.parent?.type === 'Location') { getExplorerStore().cutCopyState = { sourceParentPath: path ?? '/', + type: 'Copy', indexedArgs, ephemeralArgs, - type: 'Copy' }; } }); - useKeys([metaCtrlKey, 'KeyX'], (e) => { + useShortcut('cutObject', (e) => { e.stopPropagation(); if (explorer.parent?.type === 'Location') { getExplorerStore().cutCopyState = { sourceParentPath: path ?? '/', + type: 'Cut', indexedArgs, ephemeralArgs, - type: 'Cut' }; } }); - useKeys([metaCtrlKey, 'KeyV'], async (e) => { + useShortcut('duplicateObject', async (e) => { + e.stopPropagation(); + if (parent?.type === 'Location') { + try { + await copyFiles.mutateAsync({ + source_location_id: parent.location.id, + sources_file_path_ids: selectedFilePaths.map((p) => p.id), + target_location_id: parent.location.id, + target_location_relative_directory_path: path ?? '/' + }); + } catch (error) { + toast.error({ + title: 'Failed to duplicate file', + body: `Error: ${error}.` + }); + } + } + }); + + useShortcut('pasteObject', async (e) => { e.stopPropagation(); const parent = explorer.parent; if ( diff --git a/interface/hooks/useShortcut.ts b/interface/hooks/useShortcut.ts new file mode 100644 index 000000000..b94f1ceb0 --- /dev/null +++ b/interface/hooks/useShortcut.ts @@ -0,0 +1,210 @@ +import { useKeys } from 'rooks'; +import { useSnapshot } from 'valtio'; +import { valtioPersist } from '@sd/client'; + +import { OperatingSystem } from '../util/Platform'; +import { useOperatingSystem } from './useOperatingSystem'; + +const state = { + gridView: { + keys: { + macOS: ['Meta', '1'], + all: ['Control', '1'] + } + }, + listView: { + keys: { + macOS: ['Meta', '2'], + all: ['Control', '2'] + } + }, + mediaView: { + keys: { + macOS: ['Meta', '3'], + all: ['Control', '3'] + } + }, + showHiddenFiles: { + keys: { + macOS: ['Meta', 'Shift', '.'], + all: ['Control', 'Shift', '.'] + } + }, + showPathBar: { + keys: { + macOS: ['Alt', 'Meta', 'KeyP'], + all: ['Alt', 'Control', 'KeyP'] + } + }, + showInspector: { + keys: { + macOS: ['Meta', 'KeyI'], + all: ['Control', 'KeyI'] + } + }, + toggleJobManager: { + keys: { + macOS: ['Meta', 'KeyJ'], + all: ['Control', 'KeyJ'] + } + }, + toggleQuickPreview: { + keys: { + all: ['space'] + } + }, + toggleMetaData: { + keys: { + macOS: ['Meta', 'KeyI'], + all: ['Control', 'KeyI'] + } + }, + quickPreviewMoveBetweenItems: { + keys: { + all: ['ArrowLeft', 'ArrowRight'] + } + }, + revealNative: { + keys: { + macOS: ['Meta', 'KeyY'], + all: ['Control', 'KeyY'] + } + }, + renameObject: { + keys: { + macOS: ['Enter'], + all: ['F2'] + } + }, + rescan: { + keys: { + macOS: ['Meta', 'KeyR'], + all: ['Control', 'KeyR'] + } + }, + cutObject: { + keys: { + macOS: ['Meta', 'KeyX'], + all: ['Control', 'KeyX'] + } + }, + copyObject: { + keys: { + macOS: ['Meta', 'KeyC'], + all: ['Control', 'KeyC'] + } + }, + pasteObject: { + keys: { + macOS: ['Meta', 'KeyV'], + all: ['Control', 'KeyV'] + } + }, + duplicateObject: { + keys: { + macOS: ['Meta', 'KeyD'], + all: ['Control', 'KeyD'] + } + }, + openObject: { + keys: { + macOS: ['Meta', 'KeyO'], + all: ['Enter'] + } + }, + quickPreviewOpenNative: { + keys: { + macOS: ['Meta', 'KeyO'], + all: ['Enter'] + } + }, + delItem: { + keys: { + macOS: ['Meta', 'Backspace'], + all: ['Delete'] + } + }, + explorerEscape: { + keys: { + all: ['Escape'] + } + }, + explorerDown: { + keys: { + all: ['ArrowDown'] + } + }, + explorerUp: { + keys: { + all: ['ArrowUp'] + } + }, + explorerLeft: { + keys: { + all: ['ArrowLeft'] + } + }, + explorerRight: { + keys: { + all: ['ArrowRight'] + } + }, + navBackwardHistory: { + keys: { + macOS: ['Meta', '['], + all: ['Control', '['] + } + }, + navForwardHistory: { + keys: { + macOS: ['Meta', ']'], + all: ['Control', ']'] + } + }, + navToSettings: { + keys: { + macOS: ['Shift', 'Meta', 'KeyT'], + all: ['Shift', 'Control', 'KeyT'] + } + }, + navToOverview: { + keys: { + macOS: ['Shift', 'Meta', 'KeyO'], + all: ['Shift', 'Control', 'KeyO'] + } + }, + navExpObjects: { + keys: { + all: ['Control', 'ArrowRight'] + } + } +} satisfies Record< + string, + { + keys: { + [os in OperatingSystem | 'all']?: string[]; + }; + } +>; + +const shortcutsStore = valtioPersist('sd-shortcuts', state); + +export function useShortcutsStore() { + return useSnapshot(shortcutsStore); +} + +export function getShortcutsStore() { + return shortcutsStore; +} + +type shortcutKeys = keyof typeof state; +type osKeys = keyof (typeof state)[shortcutKeys]['keys']; + +export const useShortcut = (shortcut: shortcutKeys, func: (e: KeyboardEvent) => void) => { + const os = useOperatingSystem(); + const shortcutsStore = getShortcutsStore(); + const shortcutKeys = + shortcutsStore[shortcut].keys[os as osKeys] || shortcutsStore[shortcut].keys.all; + + useKeys(shortcutKeys, func); +};