From a87d5ef8d802b0b64dd7d38ae22c6cea82b753c4 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 8 Nov 2025 15:35:10 -0800 Subject: [PATCH] implement list multiselection --- .../components/item-card/item-card.module.css | 13 +++ .../components/item-card/item-card.tsx | 95 +++++++++++++++++-- .../item-list/helpers/item-list-controls.ts | 93 ++++++++++++++++-- .../item-list/helpers/item-list-state.ts | 22 ++++- .../item-grid-list/item-grid-list.tsx | 6 +- .../item-table-list/item-table-list.tsx | 8 +- 6 files changed, 217 insertions(+), 20 deletions(-) diff --git a/src/renderer/components/item-card/item-card.module.css b/src/renderer/components/item-card/item-card.module.css index 83e33ed94..702219dd5 100644 --- a/src/renderer/components/item-card/item-card.module.css +++ b/src/renderer/components/item-card/item-card.module.css @@ -1,13 +1,26 @@ .container { + position: relative; display: flex; flex-direction: column; width: 100%; padding: var(--theme-spacing-md); overflow: hidden; + user-select: none; background-color: var(--theme-colors-surface); border-radius: var(--theme-radius-md); } +.container.previewed { + outline: 2px dashed var(--theme-colors-primary); + outline-offset: 2px; + opacity: 0.7; +} + +.container.selected { + outline: 2px solid var(--theme-colors-primary); + outline-offset: 2px; +} + .image-container { position: relative; width: 100%; diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index c4c516da1..9cc3a7730 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -105,12 +105,17 @@ const CompactItemCard = ({ controls, data, imageUrl, + internalState, isRound, itemType, rows, withControls, }: ItemCardDerivativeProps) => { const [showControls, setShowControls] = useState(false); + const isSelected = + data && internalState && typeof data === 'object' && 'id' in data + ? internalState.isSelected((data as any).id) + : false; if (data) { const handleMouseEnter = () => { @@ -126,11 +131,34 @@ const CompactItemCard = ({ }; const handleClick = (e: React.MouseEvent) => { - // controls?.onClick?.(data, itemType, e); + if (!data || !controls || !internalState) { + return; + } + + // Don't trigger selection if clicking on interactive elements + const target = e.target as HTMLElement; + const isInteractiveElement = target.closest( + 'button, a, input, select, textarea, [role="button"]', + ); + + if (isInteractiveElement) { + return; + } + + controls.onClick?.({ + event: e, + internalState, + item: data as any, + itemType, + }); }; return ( -
+
{ const [showControls, setShowControls] = useState(false); + const isSelected = + data && internalState && typeof data === 'object' && 'id' in data + ? internalState.isSelected((data as any).id) + : false; if (data) { const handleMouseEnter = () => { @@ -202,11 +235,34 @@ const DefaultItemCard = ({ }; const handleClick = (e: React.MouseEvent) => { - // controls?.onClick?.(data, itemType, e); + if (!data || !controls || !internalState) { + return; + } + + // Don't trigger selection if clicking on interactive elements + const target = e.target as HTMLElement; + const isInteractiveElement = target.closest( + 'button, a, input, select, textarea, [role="button"]', + ); + + if (isInteractiveElement) { + return; + } + + controls.onClick?.({ + event: e, + internalState, + item: data as any, + itemType, + }); }; return ( -
+
{ const [showControls, setShowControls] = useState(false); + const isSelected = + data && internalState && typeof data === 'object' && 'id' in data + ? internalState.isSelected((data as any).id) + : false; if (data) { const handleMouseEnter = () => { @@ -279,11 +339,34 @@ const PosterItemCard = ({ }; const handleClick = (e: React.MouseEvent) => { - // controls?.onClick?.(data, itemType, e); + if (!data || !controls || !internalState) { + return; + } + + // Don't trigger selection if clicking on interactive elements + const target = e.target as HTMLElement; + const isInteractiveElement = target.closest( + 'button, a, input, select, textarea, [role="button"]', + ); + + if (isInteractiveElement) { + return; + } + + controls.onClick?.({ + event: e, + internalState, + item: data as any, + itemType, + }); }; return ( -
+
{ const controls: ItemControls = useMemo(() => { return { - onClick: ({ internalState, item, itemType }: DefaultItemControlProps) => { - if (!item) { + onClick: ({ event, internalState, item, itemType }: DefaultItemControlProps) => { + if (!item || !internalState || !event) { return; } @@ -21,16 +21,89 @@ export const useDefaultItemListControls = () => { itemType, }; - // Regular click - deselect all others and select only this item - // If this item is already the only selected item, deselect it - const selectedItems = internalState.getSelected(); - const isOnlySelected = - selectedItems.length === 1 && selectedItems[0].id === item.id; + // Check if ctrl/cmd key is held for multi-selection + if (event.ctrlKey || event.metaKey) { + const isCurrentlySelected = internalState.isSelected(item.id); - if (isOnlySelected) { - internalState.clearSelected(); + if (isCurrentlySelected) { + // Remove this item from selection + const currentSelected = internalState.getSelected(); + const filteredSelected = currentSelected.filter( + (selectedItem) => selectedItem.id !== item.id, + ); + internalState.setSelected(filteredSelected); + } else { + // Add this item to selection + const currentSelected = internalState.getSelected(); + const newSelected = [...currentSelected, itemListItem]; + internalState.setSelected(newSelected); + } + } + // Check if shift key is held for range selection + else if (event.shiftKey) { + const selectedItems = internalState.getSelected(); + const lastSelectedItem = selectedItems[selectedItems.length - 1]; + + if (lastSelectedItem) { + // Get the data array from internalState + const data = internalState.getData(); + // Filter out null/undefined values (e.g., header row) + const validData = data.filter( + (d) => d && typeof d === 'object' && 'id' in d, + ); + + // Find the indices of the last selected item and current item + const lastIndex = internalState.findItemIndex(lastSelectedItem.id); + const currentIndex = internalState.findItemIndex(item.id); + + if (lastIndex !== -1 && currentIndex !== -1) { + // Create range selection - select ALL items in the range + const startIndex = Math.min(lastIndex, currentIndex); + const stopIndex = Math.max(lastIndex, currentIndex); + + const rangeItems: ItemListItem[] = []; + for (let i = startIndex; i <= stopIndex; i++) { + const rangeItem = validData[i]; + if ( + rangeItem && + typeof rangeItem === 'object' && + 'id' in rangeItem && + '_serverId' in rangeItem + ) { + rangeItems.push({ + _serverId: (rangeItem as any)._serverId, + id: (rangeItem as any).id, + itemType, + }); + } + } + + // Merge with existing selection, avoiding duplicates + const currentSelected = internalState.getSelected(); + const newSelected = [...currentSelected]; + rangeItems.forEach((rangeItem) => { + if (!newSelected.some((selected) => selected.id === rangeItem.id)) { + newSelected.push(rangeItem); + } + }); + internalState.setSelected(newSelected); + } + } else { + // No previous selection, just select this item + internalState.setSelected([itemListItem]); + } } else { - internalState.setSelected([itemListItem]); + // Regular click - deselect all others and select only this item + // If this item is already the only selected item, deselect it + const selectedItems = internalState.getSelected(); + const isOnlySelected = + selectedItems.length === 1 && selectedItems[0].id === item.id; + + if (isOnlySelected) { + internalState.clearSelected(); + } else { + internalState.setSelected([itemListItem]); + } } }, diff --git a/src/renderer/components/item-list/helpers/item-list-state.ts b/src/renderer/components/item-list/helpers/item-list-state.ts index e38c22225..22890cb41 100644 --- a/src/renderer/components/item-list/helpers/item-list-state.ts +++ b/src/renderer/components/item-list/helpers/item-list-state.ts @@ -30,6 +30,8 @@ export interface ItemListStateActions { clearAll: () => void; clearExpanded: () => void; clearSelected: () => void; + findItemIndex: (itemId: string) => number; + getData: () => unknown[]; getExpanded: () => ItemListItem[]; getExpandedIds: () => string[]; getSelected: () => ItemListItem[]; @@ -168,7 +170,7 @@ export const initialItemListState: ItemListState = { version: 0, }; -export const useItemListState = (): ItemListStateActions => { +export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActions => { const [state, dispatch] = useReducer(itemListReducer, initialItemListState); const setExpanded = useCallback((items: ItemListItem[]) => { @@ -241,11 +243,27 @@ export const useItemListState = (): ItemListStateActions => { return itemGridSelectors.hasAnySelected(state); }, [state]); + const getData = useCallback(() => { + return getDataFn ? getDataFn() : []; + }, [getDataFn]); + + const findItemIndex = useCallback( + (itemId: string) => { + const data = getDataFn ? getDataFn() : []; + // Filter out null/undefined values (e.g., header row) + const validData = data.filter((d) => d && typeof d === 'object' && 'id' in d); + return validData.findIndex((d) => (d as any).id === itemId); + }, + [getDataFn], + ); + return useMemo( () => ({ clearAll, clearExpanded, clearSelected, + findItemIndex, + getData, getExpanded, getExpandedIds, getSelected, @@ -264,6 +282,8 @@ export const useItemListState = (): ItemListStateActions => { clearAll, clearExpanded, clearSelected, + findItemIndex, + getData, getExpanded, getExpandedIds, getSelected, diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 324e4cf02..f2dd8fada 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -273,7 +273,11 @@ export const ItemGridList = ({ const { ref: containerRef, width: containerWidth } = useElementSize(); const mergedContainerRef = useMergedRef(containerRef, rootRef); - const internalState = useItemListState(); + const getDataFn = useCallback(() => { + return data; + }, [data]); + + const internalState = useItemListState(getDataFn); const [initialize] = useOverlayScrollbars({ defer: true, diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 5b0d6e794..0bc6ed136 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -871,7 +871,7 @@ export const ItemTableList = ({ } return undefined; - }, []); + }, [pinnedLeftColumnCount, pinnedRightColumnCount]); // Handle left and right shadow visibility based on horizontal scroll useEffect(() => { @@ -917,7 +917,11 @@ export const ItemTableList = ({ [enableHeader, headerHeight, rowHeight, pinnedRowCount, size], ); - const internalState = useItemListState(); + const getDataFn = useCallback(() => { + return enableHeader ? [null, ...data] : data; + }, [data, enableHeader]); + + const internalState = useItemListState(getDataFn); const hasExpanded = internalState.hasExpanded();