feat(selection): Shift+Click range selection on grid pages (#484)

* feat(selection): add useRangeSelection hook with Shift+Click range support

Reusable hook for multi-select state across pages that show grids of
items the user can pick. Tracks the selected ID set, a click anchor,
and exposes a `toggleSelect(id, { shiftKey })` callback:

- Plain click → toggles that item and moves the anchor to it.
- Shift-click on a second item → adds every item between the anchor
  and the click target (inclusive) to the selection. The anchor moves
  to the shift-clicked item so the next shift-click extends from
  there.

Range expansion follows the items array passed to the hook, so the
caller controls the user-visible order (filtered + sorted list, not
the raw upstream array).

Implementation note: the anchor ref is snapshotted *before* the state
updater runs and written *after* it. React 18 strict mode invokes
state updater functions twice in dev to surface side effects, so any
ref mutation inside the updater would taint the second invocation and
the replay would miss the range branch.

* feat(selection): adopt Shift+Click range selection on grid pages

Wires `useRangeSelection` into the four pages that ship a multi-select
mode on top of card grids:

- Albums (passes the filtered/sorted `visibleAlbums` so range follows
  the order the user actually sees)
- RandomAlbums
- NewReleases
- Playlists

`AlbumCard.onToggleSelect` is extended to forward `{ shiftKey }`, and
the card's onClick handler reads `e.shiftKey` from the React event
and threads it through. The Playlists grid uses an inline onClick on
the card div and was updated the same way.

User-visible behaviour: in selection mode, click an item then
shift-click a later item — every item between them gets selected.
Existing single-toggle behaviour is unchanged when no shift key is
held.

* docs: changelog entry for PR #484

Logs the Shift+Click range selection on grid pages under
v1.46.0 "## Changed".
This commit is contained in:
Frank Stellmacher
2026-05-06 17:51:25 +02:00
committed by GitHub
parent 6c1deeeb7f
commit 43d75e744b
7 changed files with 101 additions and 48 deletions
+7
View File
@@ -106,6 +106,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Right-click** on an album row now opens the standard album context menu (Play / Add to queue / Play next / Add to playlist / Go to artist) instead of firing a hidden direct-play action; right-click on a Top Artists card opens the artist context menu.
* The **play count** moved from a small right-aligned column to a localized **pill right next to the album title**`11 plays` (en), `11× gespielt` (de) — since the play count is the central datum on this page.
### Multi-select — Shift+Click range selection on grid pages
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#484](https://github.com/Psychotoxical/psysonic/pull/484)**
* In multi-select mode on **Albums**, **Random Albums**, **New Releases** and **Playlists**, holding **Shift** while clicking a second card now selects every item between the anchor (last clicked) and the click target — the standard OS-level pattern. Range expansion follows the user-visible order, so filters and sort affect what gets included.
* Plain click still toggles a single item and moves the anchor to it; behaviour without Shift is unchanged.
## Fixed
### Hot cache, HTTP streaming replay, and queue source indicator
+4 -4
View File
@@ -15,7 +15,7 @@ interface AlbumCardProps {
album: SubsonicAlbum;
selected?: boolean;
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
onToggleSelect?: (id: string, opts?: { shiftKey?: boolean }) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean;
@@ -54,15 +54,15 @@ function AlbumCard({
const psyDrag = useDragDrop();
const isNewAlbum = isAlbumRecentlyAdded(album.created);
const handleClick = () => {
if (selectionMode) { onToggleSelect?.(album.id); return; }
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigate(`/album/${album.id}`);
};
return (
<div
className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
onClick={handleClick}
onClick={e => handleClick({ shiftKey: e.shiftKey })}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
+61
View File
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Multi-select state with Shift+Click range support.
*
* Pass the *currently visible* list (already filtered + sorted in the order
* the user sees it) so range expansion follows what's on screen, not the
* raw upstream data.
*
* - Plain click on an item: toggles that item and sets it as the new range anchor.
* - Shift-click on a second item: adds every item between the anchor and the
* click target (inclusive) to the selection. The anchor moves to the
* shift-clicked item so subsequent shift-clicks extend from there.
*
* The anchor is a ref, not state — moving it does not trigger re-renders.
*/
export function useRangeSelection<T extends { id: string }>(items: T[]) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const itemsRef = useRef(items);
useEffect(() => {
itemsRef.current = items;
}, [items]);
const anchorRef = useRef<string | null>(null);
const toggleSelect = useCallback((id: string, opts?: { shiftKey?: boolean }) => {
// Snapshot the anchor *before* the state updater runs. React strict mode
// invokes setState updater functions twice in dev to surface side effects,
// so any ref mutation inside the updater would taint the second invocation
// and the replay would miss the range branch.
const anchorAtCallTime = anchorRef.current;
setSelectedIds(prev => {
const next = new Set(prev);
const list = itemsRef.current;
if (opts?.shiftKey && anchorAtCallTime && anchorAtCallTime !== id) {
const startIdx = list.findIndex(x => x.id === anchorAtCallTime);
const endIdx = list.findIndex(x => x.id === id);
if (startIdx >= 0 && endIdx >= 0) {
const lo = Math.min(startIdx, endIdx);
const hi = Math.max(startIdx, endIdx);
for (let i = lo; i <= hi; i++) {
next.add(list[i].id);
}
return next;
}
}
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
anchorRef.current = id;
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
anchorRef.current = null;
}, []);
return { selectedIds, setSelectedIds, toggleSelect, clearSelection };
}
+15 -19
View File
@@ -20,6 +20,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { usePerfProbeFlags } from '../utils/perfFlags';
import { useRangeSelection } from '../hooks/useRangeSelection';
const ALBUM_GRID_GAP_PX = 16; // matches --space-4
const ALBUM_GRID_MIN_CARD_PX = 140;
@@ -63,26 +64,9 @@ export default function Albums() {
const observerTarget = useRef<HTMLDivElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
// selectedIds + toggleSelect come from useRangeSelection (declared after
// `visibleAlbums` so Shift-click range expansion follows the visible order).
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const visibleAlbums = useMemo(() => {
@@ -95,6 +79,18 @@ export default function Albums() {
return out;
}, [albums, compFilter, starredOnly, starredOverrides]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(visibleAlbums);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
resetSelection();
};
const clearSelection = () => {
setSelectionMode(false);
resetSelection();
};
const albumGridWrapRef = useRef<HTMLDivElement>(null);
const [albumGridCols, setAlbumGridCols] = useState(4);
+4 -6
View File
@@ -12,6 +12,7 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useRangeSelection } from '../hooks/useRangeSelection';
const PAGE_SIZE = 30;
@@ -43,13 +44,10 @@ export default function NewReleases() {
const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
+6 -13
View File
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { showToast } from '../utils/toast';
import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart';
import { useRangeSelection } from '../hooks/useRangeSelection';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -225,7 +226,7 @@ export default function Playlists() {
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists);
const isNavidromeServer = Boolean(
activeServerId &&
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
@@ -233,20 +234,12 @@ export default function Playlists() {
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
resetSelection();
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
resetSelection();
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
@@ -902,9 +895,9 @@ export default function Playlists() {
<div
key={pl.id}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
onClick={() => {
onClick={(e) => {
if (selectionMode) {
toggleSelect(pl.id);
toggleSelect(pl.id, { shiftKey: e.shiftKey });
} else {
navigate(`/playlists/${pl.id}`);
}
+4 -6
View File
@@ -12,6 +12,7 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useRangeSelection } from '../hooks/useRangeSelection';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
@@ -53,13 +54,10 @@ export default function RandomAlbums() {
const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {