feat(grid): user-configurable thumbnail size for grid views
Adds a "Size" pill in the FilterBar with 5 presets (XS/S/M/L/XL, 96–272px, default M=160) that drives the cell size in the Timeline, Memories, and Duplicates grids. Preference persists in localStorage. Preview filmstrip is intentionally untouched — it's a fixed-track nav rail, not a grid. Centralised in a new viewSettingsStore so every grid reads from the same source. Duplicates' virtualizer is poked on size change so row heights and the keyboard nav's column count stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
THUMB_BADGE_PICK,
|
||||
} from '../timeline/PhotoThumbnail'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
@@ -85,9 +86,11 @@ export function DuplicatesView() {
|
||||
const groupsRef = useRef(groups)
|
||||
groupsRef.current = groups
|
||||
|
||||
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
|
||||
|
||||
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||
// skip a row instead of jumping a single cell. The grid uses
|
||||
// `repeat(auto-fill, minmax(160px, 1fr))` so columns = floor(width/160).
|
||||
// `repeat(auto-fill, ${thumbnailSize}px)` so columns = floor(width/size).
|
||||
// We measure the FIRST section's grid container — every section uses
|
||||
// the same auto-fill rule so any one is representative.
|
||||
const [columns, setColumns] = useState(4)
|
||||
@@ -95,12 +98,14 @@ export function DuplicatesView() {
|
||||
// re-mount on scroll back — track the observer so we can disconnect
|
||||
// cleanly every time the ref detaches (previously this leaked).
|
||||
const sampleObserverRef = useRef<ResizeObserver | null>(null)
|
||||
const thumbnailSizeRef = useRef(thumbnailSize)
|
||||
thumbnailSizeRef.current = thumbnailSize
|
||||
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
||||
sampleObserverRef.current?.disconnect()
|
||||
sampleObserverRef.current = null
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const cols = Math.max(1, Math.floor(el.clientWidth / 160))
|
||||
const cols = Math.max(1, Math.floor(el.clientWidth / thumbnailSizeRef.current))
|
||||
setColumns(cols)
|
||||
}
|
||||
measure()
|
||||
@@ -124,6 +129,19 @@ export function DuplicatesView() {
|
||||
getItemKey: (idx) => groups[idx].group_id,
|
||||
})
|
||||
|
||||
// Resizing the thumbnails changes every section's height AND its
|
||||
// column count, but the ResizeObserver only fires on width changes —
|
||||
// so when the size flips, recompute columns directly and tell the
|
||||
// virtualizer to re-measure rows.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current?.querySelector<HTMLDivElement>('[data-dup-grid]')
|
||||
if (el) {
|
||||
const cols = Math.max(1, Math.floor(el.clientWidth / thumbnailSize))
|
||||
setColumns(cols)
|
||||
}
|
||||
virtualizer.measure()
|
||||
}, [thumbnailSize, virtualizer])
|
||||
|
||||
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
||||
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
||||
// grid so we approximate ↑/↓ via the measured `columns` count and
|
||||
@@ -273,6 +291,7 @@ export function DuplicatesView() {
|
||||
onSelectMember={handleSelectMember}
|
||||
selectedPhotos={selectedPhotos}
|
||||
isPending={discardMutation.isPending}
|
||||
thumbnailSize={thumbnailSize}
|
||||
// First section feeds the column-count sample ref —
|
||||
// every section uses the same auto-fill rule.
|
||||
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
|
||||
@@ -292,6 +311,7 @@ interface DuplicateGroupSectionProps {
|
||||
onSelectMember: (memberId: string) => void
|
||||
selectedPhotos: string[]
|
||||
isPending: boolean
|
||||
thumbnailSize: number
|
||||
/** Optional callback ref attached to this section's grid container.
|
||||
* Used by DuplicatesView to measure the rendered column count for
|
||||
* ↑/↓ keyboard navigation. Only the first section gets one. */
|
||||
@@ -305,6 +325,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||
onSelectMember,
|
||||
selectedPhotos,
|
||||
isPending,
|
||||
thumbnailSize,
|
||||
gridRef,
|
||||
}: DuplicateGroupSectionProps) {
|
||||
// Auto-pick "best" copy: highest pixel count, ties broken by file_size,
|
||||
@@ -384,14 +405,15 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-dup-grid
|
||||
className="grid gap-1 p-2"
|
||||
style={{
|
||||
// Fix both axes so each cell reserves a 160×160 box before
|
||||
// Fix both axes so each cell reserves a size×size box before
|
||||
// its thumbnail finishes loading — otherwise rows collapse to
|
||||
// the height of an empty <img> and jump when images arrive,
|
||||
// which also throws off the virtualizer's row-height measure.
|
||||
gridTemplateColumns: 'repeat(auto-fill, 160px)',
|
||||
gridAutoRows: '160px',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${thumbnailSize}px)`,
|
||||
gridAutoRows: `${thumbnailSize}px`,
|
||||
}}
|
||||
>
|
||||
{group.members.map((member) => {
|
||||
@@ -404,7 +426,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||
>
|
||||
<PhotoThumbnail
|
||||
photo={photoByMemberId.get(member.id)!}
|
||||
size={160}
|
||||
size={thumbnailSize}
|
||||
fill
|
||||
isSelected={selectedSet.has(member.id)}
|
||||
onClick={handleClick}
|
||||
|
||||
Reference in New Issue
Block a user