[ENG-1731] Explorer grid selection behaviour for windows (#2307)

* Add windows explorer selection behavior

* update media view
This commit is contained in:
nikec 2024-04-11 03:41:39 +02:00 committed by GitHub
parent 56cee3c64d
commit b71c046aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 355 additions and 49 deletions

View file

@ -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<typeof useGrid<string, ExplorerItem | undefined>>;
onActiveItemChange: (item: ExplorerItem | null) => void;
onActiveItemChange: ReturnType<typeof useKeySelection>['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}

View file

@ -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<typeof useGrid<string, ExplorerItem | undefined>>;
@ -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<ExplorerItem | null>(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<number | null>(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<ExplorerItem | null>(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<number | null>(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<ReturnType<Grid['getItem']>>) => {
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 };
};

View file

@ -43,10 +43,10 @@ export const GridView = () => {
)
});
const { activeItem } = useKeySelection(grid, { scrollToEnd: true });
const { updateActiveItem } = useKeySelection(grid, { scrollToEnd: true });
return (
<DragSelect grid={grid} onActiveItemChange={(item) => (activeItem.current = item)}>
<DragSelect grid={grid} onActiveItemChange={updateActiveItem}>
<Grid grid={grid}>
{(index) => {
const item = explorer.items?.[index];

View file

@ -175,7 +175,7 @@ export const MediaView = () => {
orderDirection
]);
const { activeItem } = useKeySelection(grid);
const { updateActiveItem } = useKeySelection(grid);
return (
<div
@ -188,7 +188,7 @@ export const MediaView = () => {
>
{isSortingByDate && <DateHeader date={date ?? ''} />}
<DragSelect grid={grid} onActiveItemChange={(item) => (activeItem.current = item)}>
<DragSelect grid={grid} onActiveItemChange={updateActiveItem}>
{virtualRows.map((virtualRow) => (
<React.Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => {

View file

@ -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();
}}
>

View file

@ -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),

View file

@ -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<OperatingSystem, 'windows' | 'macOS'> | 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
};
};

View file

@ -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 () => {
<SelectOption value="enabled">Enabled</SelectOption>
</Select>
</Setting>
<Setting
mini
title="Explorer behavior"
description="Change the explorer selection behavior"
>
<ExplorerBehaviorSelect />
</Setting>
<FeatureFlagSelector />
<InvalidateDebugPanel />
{/* <TestNotifications /> */}
@ -267,3 +278,17 @@ function CloudOriginSelect() {
</>
);
}
function ExplorerBehaviorSelect() {
const { explorerOperatingSystem } = useExplorerOperatingSystem();
return (
<Select
value={explorerOperatingSystem}
onChange={(v) => (explorerOperatingSystemStore.os = v)}
>
<SelectOption value="macOS">macOS</SelectOption>
<SelectOption value="windows">windows</SelectOption>
</Select>
);
}