diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx index 3eb0f8dc7..c98d9c6cb 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx @@ -5,7 +5,9 @@ import { ExplorerItem } from '@sd/client'; import { useExplorerContext } from '../../../Context'; import { explorerStore } from '../../../store'; +import { useExplorerOperatingSystem } from '../../../useExplorerOperatingSystem'; import { useExplorerViewContext } from '../../Context'; +import { useKeySelection } from '../useKeySelection'; import { DragSelectContext } from './context'; import { useSelectedTargets } from './useSelectedTargets'; import { getElementIndex, SELECTABLE_DATA_ATTRIBUTE } from './util'; @@ -14,7 +16,7 @@ const CHROME_REGEX = /Chrome/; interface Props extends PropsWithChildren { grid: ReturnType>; - onActiveItemChange: (item: ExplorerItem | null) => void; + onActiveItemChange: ReturnType['updateActiveItem']; } export interface Drag { @@ -27,6 +29,8 @@ export interface Drag { export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { const isChrome = CHROME_REGEX.test(navigator.userAgent); + const { explorerOperatingSystem, matchingOperatingSystem } = useExplorerOperatingSystem(); + const explorer = useExplorerContext(); const explorerView = useExplorerViewContext(); @@ -81,21 +85,38 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { function handleDragEnd() { explorerStore.isDragSelecting = false; + + const dragState = drag.current; drag.current = null; - // Set active item to the first selected target - // Targets are already sorted + // Determine if the drag event was a click + if ( + dragState?.startColumn === dragState?.endColumn && + dragState?.startRow === dragState?.endRow + ) { + return; + } + + // Update active item to the first selected target(first grid item in DOM). const target = selecto.current?.getSelectedTargets()?.[0]; const item = target && getGridItem(target)?.data; - if (item) onActiveItemChange(item); + if (item) onActiveItemChange(item, { updateFirstItem: true, setFirstItemAsChanged: true }); } function handleSelect(e: SelectoEvents['select']) { const inputEvent = e.inputEvent as MouseEvent; + let continueSelection = false; + + if (explorerOperatingSystem === 'windows') { + continueSelection = matchingOperatingSystem ? inputEvent.ctrlKey : inputEvent.metaKey; + } else { + continueSelection = inputEvent.shiftKey || inputEvent.metaKey; + } + // Handle select on mouse down if (inputEvent.type === 'mousedown') { - const element = inputEvent.shiftKey ? e.added[0] || e.removed[0] : e.selected[0]; + const element = continueSelection ? e.added[0] || e.removed[0] : e.selected[0]; if (!element) return; const item = getGridItem(element); @@ -108,7 +129,7 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { endRow: item.row }; - if (!inputEvent.shiftKey) { + if (!continueSelection) { if (explorer.selectedItems.has(item.data)) { // Keep previous selection as selecto will reset it otherwise selecto.current?.setSelectedTargets(e.beforeSelected); @@ -124,6 +145,9 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { if (e.added[0]) explorer.addSelectedItem(item.data); else explorer.removeSelectedItem(item.data); + + // Update active item for further keyboard selection. + onActiveItemChange(item.data, { updateFirstItem: true, setFirstItemAsChanged: true }); } // Handle select by drag @@ -387,7 +411,7 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { addedRows.add(item.row); } - if (inputEvent.shiftKey) { + if (continueSelection) { if (explorer.selectedItems.has(item.data)) { explorer.removeSelectedItem(item.data); } else { @@ -533,7 +557,13 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { throttleTime: isChrome ? 30 : 10000 }} selectableTargets={[`[${SELECTABLE_DATA_ATTRIBUTE}]`]} - toggleContinueSelect="shift" + toggleContinueSelect={ + explorerOperatingSystem === 'windows' + ? matchingOperatingSystem + ? 'ctrl' + : 'meta' + : [['shift'], ['meta']] + } hitRate={0} onDrag={handleDrag} onDragStart={handleDragStart} diff --git a/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx b/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx index 493de6047..0f27876f0 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx @@ -1,11 +1,11 @@ import { useGrid } from '@virtual-grid/react'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; import { ExplorerItem } from '@sd/client'; import { useShortcut } from '~/hooks'; import { useExplorerContext } from '../../Context'; -import { useQuickPreviewStore } from '../../QuickPreview/store'; -import { uniqueId } from '../../util'; +import { useExplorerOperatingSystem } from '../../useExplorerOperatingSystem'; import { useExplorerViewContext } from '../Context'; type Grid = ReturnType>; @@ -18,10 +18,29 @@ interface Options { scrollToEnd?: boolean; } +interface UpdateActiveItemOptions { + /** + * The index of the item to update. If not provided, the index will be reset. + * @default null + */ + itemIndex?: number | null; + /** + * Whether to update the first active item. + * @default false + */ + updateFirstItem?: boolean; + /** + * Whether to set the first item as changed. This is used to reset the selection. + * @default false + */ + setFirstItemAsChanged?: boolean; +} + export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: false }) => { + const { explorerOperatingSystem } = useExplorerOperatingSystem(); + const explorer = useExplorerContext(); const explorerView = useExplorerViewContext(); - const quickPreview = useQuickPreviewStore(); // The item that further selection will move from (shift + arrow for example). const activeItem = useRef(null); @@ -30,14 +49,54 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa // for the index every time we want to move to the next item. const activeItemIndex = useRef(null); - useEffect(() => { - if (quickPreview.open) return; - activeItem.current = [...explorer.selectedItems][0] ?? null; - }, [explorer.selectedItems, quickPreview.open]); + // The first active item that acts as a head. + // Only used for windows OS to keep track of the first selected item. + const firstActiveItem = useRef(null); + // The index of the first active item. + // Only used for windows OS to keep track of the first selected item index. + const firstActiveItemIndex = useRef(null); + + // Whether the first active item has been changed. + // Only used for windows OS to keep track whether selection should be reset. + const hasFirstActiveItemChanged = useRef(true); + + // Reset active item when selection changes, as the active item + // might not be in the selection anymore (further lookups are handled in handleNavigation). + useEffect(() => { + activeItem.current = null; + }, [explorer.selectedItems]); + + // Reset active item index when items change, + // as we can't guarantee the item is still in the same position useEffect(() => { activeItemIndex.current = null; - }, [explorer.items, explorer.selectedItems]); + firstActiveItemIndex.current = null; + }, [explorer.items]); + + const updateFirstActiveItem = useCallback( + (item: ExplorerItem | null, options: UpdateActiveItemOptions = {}) => { + if (explorerOperatingSystem !== 'windows') return; + + firstActiveItem.current = item; + firstActiveItemIndex.current = options.itemIndex ?? null; + if (options.setFirstItemAsChanged) hasFirstActiveItemChanged.current = true; + }, + [explorerOperatingSystem] + ); + + const updateActiveItem = useCallback( + (item: ExplorerItem | null, options: UpdateActiveItemOptions = {}) => { + // Timeout so the useEffect doesn't override it + setTimeout(() => { + activeItem.current = item; + activeItemIndex.current = options.itemIndex ?? null; + }); + + if (options.updateFirstItem) updateFirstActiveItem(item, options); + }, + [updateFirstActiveItem] + ); const scrollToItem = (item: NonNullable>) => { if (!explorer.scrollRef.current || !explorerView.ref.current) return; @@ -74,7 +133,7 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa }; const handleNavigation = (e: KeyboardEvent, direction: 'up' | 'down' | 'left' | 'right') => { - if (!explorerView.selectable) return; + if (!explorerView.selectable || !explorer.items) return; e.preventDefault(); e.stopPropagation(); @@ -88,21 +147,44 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa explorer.resetSelectedItems([item.data]); scrollToItem(item); + updateActiveItem(item.data, { itemIndex: 0, updateFirstItem: true }); + return; } let currentItemIndex = activeItemIndex.current; - // Find current index if we don't have the index stored + // Check for any mismatches between the stored index and the current item + if (currentItemIndex !== null) { + if (activeItem.current) { + const itemAtActiveIndex = explorer.items[currentItemIndex]; + const uniqueId = itemAtActiveIndex && explorer.getItemUniqueId(itemAtActiveIndex); + if (uniqueId !== explorer.getItemUniqueId(activeItem.current)) { + currentItemIndex = null; + } + } else { + currentItemIndex = null; + } + } + + // Find index of current active item if (currentItemIndex === null) { - const currentItem = activeItem.current; - if (!currentItem) return; + let currentItem = activeItem.current; - const index = explorer.items?.findIndex( - (item) => uniqueId(item) === uniqueId(currentItem) - ); + if (!currentItem) { + const [item] = explorer.selectedItems; + if (!item) return; - if (index === undefined || index === -1) return; + currentItem = item; + } + + const currentItemId = explorer.getItemUniqueId(currentItem); + + const index = explorer.items.findIndex((item) => { + return explorer.getItemUniqueId(item) === currentItemId; + }); + + if (index === -1) return; currentItemIndex = index; } @@ -125,28 +207,142 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa newIndex += 1; } + // Adjust index if it's out of bounds + if (direction === 'down' && newIndex > explorer.items.length - 1) { + // Check if we're at the last row + if (grid.getItem(currentItemIndex)?.row === grid.rowCount - 1) return; + + // By default select the last index in the grid if running on windows, + // otherwise only if we're out of bounds by one item + if ( + explorerOperatingSystem === 'windows' || + newIndex - (explorer.items.length - 1) === 1 + ) { + newIndex = explorer.items.length - 1; + } + } + const newSelectedItem = grid.getItem(newIndex); if (!newSelectedItem?.data) return; if (!e.shiftKey) { explorer.resetSelectedItems([newSelectedItem.data]); - } else if (!explorer.isItemSelected(newSelectedItem.data)) { + } else if ( + explorerOperatingSystem !== 'windows' && + !explorer.isItemSelected(newSelectedItem.data) + ) { explorer.addSelectedItem(newSelectedItem.data); + } else if (explorerOperatingSystem === 'windows') { + let firstItemId = firstActiveItem.current + ? explorer.getItemUniqueId(firstActiveItem.current) + : undefined; + + let firstItemIndex = firstActiveItemIndex.current; + + // Check if the firstActiveItem is still in the selection. If not, + // update the firstActiveItem to the current active item. + if (firstActiveItem.current && explorer.selectedItems.has(firstActiveItem.current)) { + let searchIndex = firstItemIndex === null; + + if (firstItemIndex !== null) { + const itemAtIndex = explorer.items[firstItemIndex]; + const uniqueId = itemAtIndex && explorer.getItemUniqueId(itemAtIndex); + if (uniqueId !== firstItemId) searchIndex = true; + } + + // Search for the firstActiveItem index if we're missing the index or the ExplorerItem + // at the stored index position doesn't match with the firstActiveItem + if (searchIndex) { + const item = explorer.items[currentItemIndex]; + if (!item) return; + + if (explorer.getItemUniqueId(item) === firstItemId) { + firstItemIndex = currentItemIndex; + } else { + const index = explorer.items.findIndex((item) => { + return explorer.getItemUniqueId(item) === firstItemId; + }); + + if (index === -1) return; + + firstItemIndex = index; + } + + updateFirstActiveItem(firstActiveItem.current, { itemIndex: firstItemIndex }); + } + } else { + const item = explorer.items[currentItemIndex]; + if (!item) return; + + firstItemId = explorer.getItemUniqueId(item); + firstItemIndex = currentItemIndex; + + updateFirstActiveItem(item, { itemIndex: firstItemIndex }); + } + + if (firstItemIndex === null) return; + + const addItems: ExplorerItem[] = []; + const removeItems: ExplorerItem[] = []; + + // Determine if we moved further away from the first selected item. + // This is used to determine if we should add or remove items from the selection. + let movedAwayFromFirstItem = false; + + if (firstItemIndex === currentItemIndex) { + movedAwayFromFirstItem = newIndex !== currentItemIndex; + } else if (firstItemIndex < currentItemIndex) { + movedAwayFromFirstItem = newIndex > currentItemIndex; + } else { + movedAwayFromFirstItem = newIndex < currentItemIndex; + } + + // Determine if the new index is on the other side + // of the firstActiveItem(head) based on the current index. + const isIndexOverHead = (index: number) => + (currentItemIndex < firstItemIndex && index > firstItemIndex) || + (currentItemIndex > firstItemIndex && index < firstItemIndex); + + const itemsCount = + Math.abs(currentItemIndex - newIndex) + (isIndexOverHead(newIndex) ? 1 : 0); + + for (let i = 0; i < itemsCount; i++) { + const _i = i + (movedAwayFromFirstItem ? 1 : 0); + const index = currentItemIndex + (currentItemIndex < newIndex ? _i : -_i); + + const item = explorer.items[index]; + if (!item || explorer.getItemUniqueId(item) === firstItemId) continue; + + const addItem = isIndexOverHead(index) || movedAwayFromFirstItem; + (addItem ? addItems : removeItems).push(item); + } + + if (hasFirstActiveItemChanged.current) { + if (firstActiveItem.current) addItems.push(firstActiveItem.current); + explorer.resetSelectedItems(addItems); + hasFirstActiveItemChanged.current = false; + } else { + if (addItems.length > 0) explorer.addSelectedItem(addItems); + if (removeItems.length > 0) explorer.removeSelectedItem(removeItems); + } } - // Timeout so useEffects don't override it - setTimeout(() => { - activeItem.current = newSelectedItem.data!; - activeItemIndex.current = newIndex; - }); + updateActiveItem(newSelectedItem.data, { itemIndex: newIndex }); + updateFirstActiveItem( + e.shiftKey ? firstActiveItem.current ?? newSelectedItem.data : newSelectedItem.data, + { itemIndex: e.shiftKey ? firstActiveItemIndex.current ?? currentItemIndex : newIndex } + ); scrollToItem(newSelectedItem); }; - useShortcut('explorerUp', (e) => handleNavigation(e, 'up')); - useShortcut('explorerDown', (e) => handleNavigation(e, 'down')); - useShortcut('explorerLeft', (e) => handleNavigation(e, 'left')); - useShortcut('explorerRight', (e) => handleNavigation(e, 'right')); + // Debounce keybinds to prevent weird execution order + const debounce = useDebouncedCallback((fn: () => void) => fn(), 10); - return { activeItem }; + useShortcut('explorerUp', (e) => debounce(() => handleNavigation(e, 'up'))); + useShortcut('explorerDown', (e) => debounce(() => handleNavigation(e, 'down'))); + useShortcut('explorerLeft', (e) => debounce(() => handleNavigation(e, 'left'))); + useShortcut('explorerRight', (e) => debounce(() => handleNavigation(e, 'right'))); + + return { updateActiveItem, updateFirstActiveItem }; }; diff --git a/interface/app/$libraryId/Explorer/View/GridView/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/index.tsx index 82f069426..ea1bd6974 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/index.tsx @@ -43,10 +43,10 @@ export const GridView = () => { ) }); - const { activeItem } = useKeySelection(grid, { scrollToEnd: true }); + const { updateActiveItem } = useKeySelection(grid, { scrollToEnd: true }); return ( - (activeItem.current = item)}> + {(index) => { const item = explorer.items?.[index]; diff --git a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx index 83c3652f5..cdf62f257 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx @@ -175,7 +175,7 @@ export const MediaView = () => { orderDirection ]); - const { activeItem } = useKeySelection(grid); + const { updateActiveItem } = useKeySelection(grid); return (
{ > {isSortingByDate && } - (activeItem.current = item)}> + {virtualRows.map((virtualRow) => ( {columnVirtualizer.getVirtualItems().map((virtualColumn) => { diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index b0f20c074..1a423093d 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -22,6 +22,7 @@ import { useQuickPreviewContext } from '../QuickPreview/Context'; import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store'; import { explorerStore } from '../store'; import { useExplorerDroppable } from '../useExplorerDroppable'; +import { useExplorerOperatingSystem } from '../useExplorerOperatingSystem'; import { useExplorerSearchParams } from '../util'; import { ViewContext, type ExplorerViewContext } from './Context'; import { DragScrollable } from './DragScrollable'; @@ -36,6 +37,8 @@ export interface ExplorerViewProps } export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { + const { explorerOperatingSystem, matchingOperatingSystem } = useExplorerOperatingSystem(); + const explorer = useExplorerContext(); const [isContextMenuOpen, isRenaming, drag] = useSelector(explorerStore, (s) => [ s.isContextMenuOpen, @@ -144,7 +147,15 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { ref={ref} className="flex flex-1" onMouseDown={(e) => { - if (e.button === 2 || (e.button === 0 && e.shiftKey)) return; + if (e.button !== 0) return; + + const isWindowsExplorer = + explorerOperatingSystem === 'windows' && matchingOperatingSystem; + + // Prevent selection reset when holding shift or ctrl/cmd + // This is to allow drag multi-selection + if (e.shiftKey || (isWindowsExplorer ? e.ctrlKey : e.metaKey)) return; + explorer.selectedItems.size !== 0 && explorer.resetSelectedItems(); }} > diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 4192a4e43..368af4111 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -180,30 +180,46 @@ function useSelectedItems(items: ExplorerItem[] | null) { [itemsMap, selectedItemHashes] ); + const getItemUniqueId = useCallback( + (item: ExplorerItem) => itemHashesWeakMap.current.get(item) ?? uniqueId(item), + [] + ); + return { selectedItems, selectedItemHashes, + getItemUniqueId, addSelectedItem: useCallback( - (item: ExplorerItem) => { - selectedItemHashes.value.add(uniqueId(item)); + (item: ExplorerItem | ExplorerItem[]) => { + const items = Array.isArray(item) ? item : [item]; + + for (let i = 0; i < items.length; i++) { + selectedItemHashes.value.add(getItemUniqueId(items[i]!)); + } + updateHashes(); }, - [selectedItemHashes.value, updateHashes] + [getItemUniqueId, selectedItemHashes.value, updateHashes] ), removeSelectedItem: useCallback( - (item: ExplorerItem) => { - selectedItemHashes.value.delete(uniqueId(item)); + (item: ExplorerItem | ExplorerItem[]) => { + const items = Array.isArray(item) ? item : [item]; + + for (let i = 0; i < items.length; i++) { + selectedItemHashes.value.delete(getItemUniqueId(items[i]!)); + } + updateHashes(); }, - [selectedItemHashes.value, updateHashes] + [getItemUniqueId, selectedItemHashes.value, updateHashes] ), resetSelectedItems: useCallback( (items?: ExplorerItem[]) => { selectedItemHashes.value.clear(); - items?.forEach((item) => selectedItemHashes.value.add(uniqueId(item))); + items?.forEach((item) => selectedItemHashes.value.add(getItemUniqueId(item))); updateHashes(); }, - [selectedItemHashes.value, updateHashes] + [getItemUniqueId, selectedItemHashes.value, updateHashes] ), isItemSelected: useCallback( (item: ExplorerItem) => selectedItems.has(item), diff --git a/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx b/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx new file mode 100644 index 000000000..b7d5a9735 --- /dev/null +++ b/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { proxy, useSnapshot } from 'valtio'; +import { useOperatingSystem } from '~/hooks'; +import { OperatingSystem } from '~/util/Platform'; + +export const explorerOperatingSystemStore = proxy({ + os: undefined as Extract | undefined +}); + +// This hook is used to determine the operating system behavior of the explorer. +export const useExplorerOperatingSystem = () => { + const operatingSystem = useOperatingSystem(true); + const store = useSnapshot(explorerOperatingSystemStore); + + useEffect(() => { + if (store.os) return; + explorerOperatingSystemStore.os = operatingSystem === 'windows' ? 'windows' : 'macOS'; + }, [operatingSystem, store.os]); + + const explorerOperatingSystem = + store.os ?? (operatingSystem === 'windows' ? 'windows' : 'macOS'); + + return { + operatingSystem, + explorerOperatingSystem, + matchingOperatingSystem: operatingSystem === explorerOperatingSystem + }; +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx b/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx index 8e8e45c68..44eaf449c 100644 --- a/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx @@ -25,6 +25,10 @@ import { import { toggleRenderRects } from '~/hooks'; import { usePlatform } from '~/util/Platform'; +import { + explorerOperatingSystemStore, + useExplorerOperatingSystem +} from '../../Explorer/useExplorerOperatingSystem'; import Setting from '../../settings/Setting'; export default () => { @@ -148,6 +152,13 @@ export default () => { Enabled + + + {/* */} @@ -267,3 +278,17 @@ function CloudOriginSelect() { ); } + +function ExplorerBehaviorSelect() { + const { explorerOperatingSystem } = useExplorerOperatingSystem(); + + return ( + + ); +}