diff --git a/src/App.tsx b/src/App.tsx index a3792836..67aaad41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ import { } from 'lucide-react'; import TitleBar from './window/TitleBar'; import CommunityPage from './components/panel/CommunityPage'; -import MainLibrary, { ColumnWidths } from './components/panel/MainLibrary'; +import MainLibrary, { ColumnWidths, LibraryColumnKey } from './components/panel/MainLibrary'; import FolderTree from './components/panel/FolderTree'; import Editor from './components/panel/Editor'; import Controls from './components/panel/right/ControlsPanel'; @@ -457,6 +457,13 @@ function App() { rating: 15, color: 15, }); + const [visibleListColumns, setVisibleListColumns] = useState([ + 'thumbnail', + 'name', + 'date', + 'rating', + 'color', + ]); const { showContextMenu } = useContextMenu(); const [thumbnails, setThumbnails] = useState>({}); const { requestThumbnails, clearThumbnailQueue } = useThumbnails(); @@ -5013,6 +5020,8 @@ function App() { onNavigateToCommunity={() => setActiveView('community')} listColumnWidths={listColumnWidths} setListColumnWidths={setListColumnWidths} + visibleListColumns={visibleListColumns} + setVisibleListColumns={setVisibleListColumns} /> )} {rootPath && ( diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index d17f577c..6b404253 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -51,8 +51,162 @@ export interface ColumnWidths { date: number; rating: number; color: number; + iso?: number; + aperture?: number; + shutter_speed?: number; + focal_length?: number; + date_taken?: number; } +export type LibraryColumnKey = keyof ColumnWidths; + +const DEFAULT_COLUMN_WIDTHS: Required = { + thumbnail: 8, + name: 32, + date: 24, + rating: 12, + color: 10, + iso: 10, + aperture: 12, + shutter_speed: 14, + focal_length: 14, + date_taken: 18, +}; + +const BASE_LIST_COLUMNS: LibraryColumnKey[] = ['thumbnail', 'name', 'date', 'rating', 'color']; +const EXIF_SORT_COLUMN_KEYS: LibraryColumnKey[] = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; + +const SORT_KEY_TO_COLUMN_KEY: Partial> = { + date_taken: 'date_taken', + iso: 'iso', + shutter_speed: 'shutter_speed', + aperture: 'aperture', + focal_length: 'focal_length', +}; + +const getResolvedColumnWidths = (widths: ColumnWidths, visibleColumns: LibraryColumnKey[]) => { + const rawWidths = visibleColumns.reduce( + (acc, key) => { + acc[key] = widths[key] ?? DEFAULT_COLUMN_WIDTHS[key]; + return acc; + }, + {} as Record, + ); + + const total = Object.values(rawWidths).reduce((sum, value) => sum + value, 0) || 1; + + return visibleColumns.reduce( + (acc, key) => { + acc[key] = (rawWidths[key] / total) * 100; + return acc; + }, + {} as Record, + ); +}; + +const getExifValue = (exif: any, keys: string[]) => { + if (!exif) return undefined; + for (const key of keys) { + const value = exif?.[key]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return undefined; +}; + +const formatExifValue = (column: LibraryColumnKey, exif: any) => { + switch (column) { + case 'iso': { + const iso = getExifValue(exif, ['PhotographicSensitivity', 'ISOSpeedRatings', 'ISOSpeed', 'ISO']); + return iso !== undefined ? String(iso) : '—'; + } + case 'aperture': { + const aperture = getExifValue(exif, ['FNumber', 'ApertureValue', 'aperture']); + if (aperture === undefined) return '—'; + const apertureString = String(aperture).trim(); + return /^f\//i.test(apertureString) ? apertureString : `f/${apertureString}`; + } + case 'shutter_speed': { + const shutter = getExifValue(exif, ['ExposureTime', 'ShutterSpeedValue', 'shutter_speed']); + return shutter !== undefined ? String(shutter) : '—'; + } + case 'focal_length': { + const focalLength = getExifValue(exif, ['FocalLengthIn35mmFilm', 'FocalLength', 'FocalLengthIn35mmFormat']); + if (focalLength === undefined) return '—'; + const focalLengthString = String(focalLength).trim(); + return /mm$/i.test(focalLengthString) ? focalLengthString : `${focalLengthString} mm`; + } + case 'date_taken': { + const dateTaken = getExifValue(exif, ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'date_taken']); + return dateTaken !== undefined ? String(dateTaken) : '—'; + } + default: + return '—'; + } +}; + + +const parseShutterSpeedToSeconds = (value: any): number | null => { + if (value === undefined || value === null) return null; + + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 0 ? value : null; + } + + let normalized = String(value).trim(); + if (!normalized) return null; + + normalized = normalized + .replace(/[″”]/g, ' s') + .replace(/[′]/g, '') + .replace(/seconds?/gi, 's') + .replace(/sec(?:onds?)?/gi, 's') + .replace(/\s+/g, ' ') + .trim(); + + if (normalized.includes('/')) { + const fractionMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)\s*\/\s*([0-9]+(?:\.[0-9]+)?)/); + if (fractionMatch) { + const numerator = Number(fractionMatch[1]); + const denominator = Number(fractionMatch[2]); + if (Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0) { + return numerator / denominator; + } + } + } + + const numberMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)/); + if (!numberMatch) return null; + + const parsed = Number(numberMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +const getSortValue = (image: ImageFile, sortKey: string) => { + switch (sortKey) { + case 'shutter_speed': { + const shutterValue = getExifValue(image.exif, ['ExposureTime', 'ShutterSpeedValue', 'shutter_speed']); + return parseShutterSpeedToSeconds(shutterValue); + } + default: + return null; + } +}; + +const LIST_COLUMN_DEFS: Array<{ key: LibraryColumnKey; title: string; sortKey?: string }> = [ + { key: 'thumbnail', title: '' }, + { key: 'name', title: 'Name', sortKey: 'name' }, + { key: 'date', title: 'Modified', sortKey: 'date' }, + { key: 'rating', title: 'Rating', sortKey: 'rating' }, + { key: 'color', title: 'Label' }, + { key: 'iso', title: 'ISO', sortKey: 'iso' }, + { key: 'aperture', title: 'Aperture', sortKey: 'aperture' }, + { key: 'shutter_speed', title: 'Shutter', sortKey: 'shutter_speed' }, + { key: 'focal_length', title: 'Focal Length', sortKey: 'focal_length' }, + { key: 'date_taken', title: 'Date Taken', sortKey: 'date_taken' }, +]; + interface DropdownMenuProps { buttonContent: any; buttonTitle: string; @@ -122,6 +276,8 @@ interface MainLibraryProps { onNavigateToCommunity(): void; listColumnWidths: ColumnWidths; setListColumnWidths: React.Dispatch>; + visibleListColumns: LibraryColumnKey[]; + setVisibleListColumns: React.Dispatch>; } interface SearchInputProps { @@ -133,8 +289,10 @@ interface SearchInputProps { interface SortOptionsProps { sortCriteria: SortCriteria; - setSortCriteria(criteria: SortCriteria): void; + setSortCriteria(criteria: SortCriteria | ((prev: SortCriteria) => SortCriteria)): void; sortOptions: Array & { label?: string; disabled?: boolean }>; + selectedSortFields: string[]; + onToggleSortField: (key: string) => void; } interface ImageLayer { @@ -159,7 +317,9 @@ interface ThumbnailProps { interface ListItemProps extends ThumbnailProps { modified: number; + exif?: any; columnWidths: ColumnWidths; + visibleColumns: LibraryColumnKey[]; } interface ThumbnailSizeOption { @@ -190,9 +350,11 @@ interface ViewOptionsProps { onSelectAspectRatio(aspectRatio: ThumbnailAspectRatio): any; setFilterCriteria(criteria: Partial): void; setLibraryViewMode(mode: LibraryViewMode): void; - setSortCriteria(criteria: SortCriteria): void; + setSortCriteria(criteria: SortCriteria | ((prev: SortCriteria) => SortCriteria)): void; sortCriteria: SortCriteria; sortOptions: Array & { label?: string; disabled?: boolean }>; + selectedSortFields: string[]; + onToggleSortField(key: string): void; thumbnailSize: ThumbnailSize; thumbnailAspectRatio: ThumbnailAspectRatio; } @@ -258,19 +420,23 @@ function ListHeader({ containerRef, sortCriteria, onSortChange, + visibleColumns, }: { widths: ColumnWidths; setWidths: React.Dispatch>; containerRef: React.RefObject; sortCriteria: SortCriteria; onSortChange: (key: string) => void; + visibleColumns: LibraryColumnKey[]; }) { - const handleResize = (e: React.MouseEvent, leftCol: keyof ColumnWidths, rightCol: keyof ColumnWidths) => { + const resolvedWidths = getResolvedColumnWidths(widths, visibleColumns); + + const handleResize = (e: React.MouseEvent, leftCol: LibraryColumnKey, rightCol: LibraryColumnKey) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; - const startLeftWidth = widths[leftCol]; - const startRightWidth = widths[rightCol]; + const startLeftWidth = widths[leftCol] ?? DEFAULT_COLUMN_WIDTHS[leftCol]; + const startRightWidth = widths[rightCol] ?? DEFAULT_COLUMN_WIDTHS[rightCol]; const containerWidth = containerRef.current?.clientWidth || 1000; const onMouseMove = (moveEvent: MouseEvent) => { @@ -305,61 +471,49 @@ function ListHeader({ document.addEventListener('mouseup', onMouseUp); }; - const Column = ({ - title, - widthKey, - nextKey, - sortKey, - }: { - title: string; - widthKey: keyof ColumnWidths; - nextKey?: keyof ColumnWidths; - sortKey?: string; - }) => { - const isSorted = sortCriteria.key === sortKey; - const isAsc = sortCriteria.order === SortDirection.Ascending; + const visibleDefs = LIST_COLUMN_DEFS.filter((column) => visibleColumns.includes(column.key)); - return ( -
sortKey && onSortChange(sortKey)} - > - - {title} - - {isSorted && ( - - {isAsc ? : } - - )} - {nextKey && ( + return ( +
+ {visibleDefs.map((column, index) => { + const nextColumn = visibleDefs[index + 1]; + const isSorted = sortCriteria.key === column.sortKey; + const isAsc = sortCriteria.order === SortDirection.Ascending; + + return (
e.stopPropagation()} - onMouseDown={(e) => handleResize(e, widthKey, nextKey)} + key={column.key} + style={{ width: `${resolvedWidths[column.key]}%` }} + className={`relative flex items-center px-3 h-full select-none ${ + column.sortKey ? 'cursor-pointer hover:bg-bg-primary/50 transition-colors' : '' + }`} + onClick={() => column.sortKey && onSortChange(column.sortKey)} > -
+ + {column.title} + + {isSorted && ( + + {isAsc ? : } + + )} + {nextColumn && ( +
e.stopPropagation()} + onMouseDown={(e) => handleResize(e, column.key, nextColumn.key)} + > +
+
+ )}
- )} -
- ); - }; - - return ( -
- - - - - + ); + })}
); } @@ -834,8 +988,21 @@ function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) ); } -function SortOptions({ sortCriteria, setSortCriteria, sortOptions }: SortOptionsProps) { +function SortOptions({ + sortCriteria, + setSortCriteria, + sortOptions, + selectedSortFields, + onToggleSortField, +}: SortOptionsProps) { + const selectableSortFieldKeys = useMemo(() => new Set(['iso', 'shutter_speed', 'aperture', 'focal_length']), []); + const handleKeyChange = (key: string) => { + if (selectableSortFieldKeys.has(key)) { + onToggleSortField(key); + return; + } + setSortCriteria((prev: SortCriteria) => ({ ...prev, key })); }; @@ -889,7 +1056,11 @@ function SortOptions({ sortCriteria, setSortCriteria, sortOptions }: SortOptions
{sortOptions.map((option) => { - const isSelected = sortCriteria.key === option.key; + const isSelectableSortField = selectableSortFieldKeys.has(option.key); + const isSelected = isSelectableSortField + ? selectedSortFields.includes(option.key) + : sortCriteria.key === option.key; + return (
- +
@@ -1023,8 +1202,10 @@ function ListItem({ rating, tags, modified, + exif, aspectRatio: thumbnailAspectRatio, columnWidths, + visibleColumns, }: ListItemProps) { const [showPlaceholder, setShowPlaceholder] = useState(false); const [layers, setLayers] = useState([]); @@ -1099,6 +1280,8 @@ function ListItem({ : isSelected ? 'ring-1 ring-inset ring-accent/50 bg-accent/5' : 'hover:bg-surface/80'; + const resolvedWidths = getResolvedColumnWidths(columnWidths, visibleColumns); + const metaTextClassName = 'flex items-center px-3 h-full overflow-hidden'; return (
onImageDoubleClick(path)} > -
-
- {layers.length > 0 && ( -
- {layers.map((layer) => ( -
handleTransitionEnd(layer.id)} - > - {thumbnailAspectRatio === ThumbnailAspectRatio.Contain && ( + {visibleColumns.includes('thumbnail') && ( +
+
+ {layers.length > 0 && ( +
+ {layers.map((layer) => ( +
handleTransitionEnd(layer.id)} + > + {thumbnailAspectRatio === ThumbnailAspectRatio.Contain && ( + + )} - )} - {baseName} -
- ))} -
- )} - - - {layers.length === 0 && showPlaceholder && ( - - - +
+ ))} +
)} - + + + {layers.length === 0 && showPlaceholder && ( + + + + )} + +
-
+ )} - {/* Name */} -
- - {baseName} - - {isVirtualCopy && ( + {visibleColumns.includes('name') && ( +
- VC + {baseName} - )} -
+ {isVirtualCopy && ( + + VC + + )} +
+ )} -
- - {dateStr} - -
+ {visibleColumns.includes('date') && ( +
+ + {dateStr} + +
+ )} + + {visibleColumns.includes('rating') && ( +
+ {rating > 0 ? ( +
+ + {rating} + + +
+ ) : ( + + — + + )} +
+ )} -
- {rating > 0 && ( -
- - - {rating} + {visibleColumns.includes('color') && ( +
+ {colorLabel ? ( +
+
+ + {colorLabel.name} + +
+ ) : ( + + — -
- )} -
+ )} +
+ )} -
- {colorLabel && ( -
-
- - {colorLabel.name} + {LIST_COLUMN_DEFS + .filter( + (column) => + EXIF_SORT_COLUMN_KEYS.includes(column.key) && + visibleColumns.includes(column.key) && + resolvedWidths[column.key] !== undefined, + ) + .map((column) => ( +
+ + {formatExifValue(column.key, exif)}
- )} -
+ ))}
); } @@ -1417,6 +1634,7 @@ const Row = ({ gap, isListView, columnWidths, + visibleColumns, }: any) => { const row = rows[index]; if (row.type === 'footer') return null; @@ -1496,7 +1714,9 @@ const Row = ({ tags={imageFile.tags || []} aspectRatio={thumbnailAspectRatio} modified={imageFile.modified} + exif={imageFile.exif} columnWidths={columnWidths} + visibleColumns={visibleColumns} /> ) : ( { + if (sortCriteria.key !== 'shutter_speed') { + return imageList; + } + + const direction = sortCriteria.order === SortDirection.Descening ? -1 : 1; + + return [...imageList].sort((a, b) => { + const aValue = getSortValue(a, sortCriteria.key); + const bValue = getSortValue(b, sortCriteria.key); + + if (aValue === null && bValue === null) return 0; + if (aValue === null) return 1; + if (bValue === null) return -1; + if (aValue === bValue) return 0; + + return aValue < bValue ? -1 * direction : 1 * direction; + }); + }, [imageList, sortCriteria.key, sortCriteria.order]); + const groups = useMemo(() => { if (libraryViewMode === LibraryViewMode.Flat) return null; - return groupImagesByFolder(imageList, currentFolderPath); - }, [imageList, currentFolderPath, libraryViewMode]); + return groupImagesByFolder(sortedImageList, currentFolderPath); + }, [sortedImageList, currentFolderPath, libraryViewMode]); + + const selectableSortFieldKeys = useMemo( + () => ['iso', 'shutter_speed', 'aperture', 'focal_length'] as LibraryColumnKey[], + [], + ); + const selectedSortFields = useMemo( + () => visibleListColumns.filter((column) => selectableSortFieldKeys.includes(column)), + [visibleListColumns, selectableSortFieldKeys], + ); + + const resolvedListColumnWidths = useMemo( + () => getResolvedColumnWidths(listColumnWidths, visibleListColumns), + [listColumnWidths, visibleListColumns], + ); const handleSortChange = useCallback( (criteria: SortCriteria | ((prev: SortCriteria) => SortCriteria)) => { @@ -1631,6 +1887,28 @@ export default function MainLibrary({ [onClearSelection, setSortCriteria], ); + const handleToggleSortField = useCallback( + (key: string) => { + const columnKey = SORT_KEY_TO_COLUMN_KEY[key]; + if (!columnKey || !selectableSortFieldKeys.includes(columnKey)) return; + + onClearSelection(); + + const isSelected = visibleListColumns.includes(columnKey); + + if (isSelected) { + setVisibleListColumns((prev) => prev.filter((column) => column !== columnKey)); + setSortCriteria((current) => + current.key === key ? { key: 'name', order: SortDirection.Ascending } : current, + ); + } else { + setVisibleListColumns((prev) => [...prev, columnKey]); + setSortCriteria({ key, order: SortDirection.Ascending }); + } + }, + [onClearSelection, selectableSortFieldKeys, setSortCriteria, visibleListColumns], + ); + const sortOptions = useMemo(() => { const exifEnabled = appSettings?.enableExifReading ?? false; return [ @@ -1663,7 +1941,7 @@ export default function MainLibrary({ ? 1 : Math.max(1, Math.floor((availableWidth + ITEM_GAP) / (minThumbWidth + ITEM_GAP))); const itemWidth = isListView ? availableWidth : (availableWidth - ITEM_GAP * (columnCount - 1)) / columnCount; - const listRowHeight = Math.max(36, Math.min(300, (availableWidth * listColumnWidths.thumbnail) / 100)); + const listRowHeight = Math.max(36, Math.min(300, (availableWidth * resolvedListColumnWidths.thumbnail) / 100)); const rowHeight = isListView ? listRowHeight : itemWidth + ITEM_GAP; const headerHeight = 40; @@ -1671,7 +1949,7 @@ export default function MainLibrary({ let found = false; if (libraryViewMode === LibraryViewMode.Recursive) { - const grps = groupImagesByFolder(imageList, currentFolderPath); + const grps = groupImagesByFolder(sortedImageList, currentFolderPath); for (const group of grps) { if (group.images.length === 0) continue; targetTop += headerHeight; @@ -1684,7 +1962,7 @@ export default function MainLibrary({ targetTop += Math.ceil(group.images.length / columnCount) * rowHeight; } } else { - const idx = imageList.findIndex((img) => img.path === activePath); + const idx = sortedImageList.findIndex((img) => img.path === activePath); if (idx !== -1) { targetTop = Math.floor(idx / columnCount) * rowHeight; found = true; @@ -1733,7 +2011,7 @@ export default function MainLibrary({ : Math.max(1, Math.floor((availableWidth + ITEM_GAP) / (minThumbWidth + ITEM_GAP))); const itemWidth = isListView ? availableWidth : (availableWidth - ITEM_GAP * (columnCount - 1)) / columnCount; - const listRowHeight = Math.max(36, Math.min(300, (availableWidth * listColumnWidths.thumbnail) / 100)); + const listRowHeight = Math.max(36, Math.min(300, (availableWidth * resolvedListColumnWidths.thumbnail) / 100)); const rowHeight = isListView ? listRowHeight : itemWidth + ITEM_GAP; const headerHeight = 40; @@ -1741,7 +2019,7 @@ export default function MainLibrary({ let found = false; if (libraryViewMode === LibraryViewMode.Recursive) { - const groups = groupImagesByFolder(imageList, currentFolderPath); + const groups = groupImagesByFolder(sortedImageList, currentFolderPath); for (const group of groups) { if (group.images.length === 0) continue; @@ -1759,7 +2037,7 @@ export default function MainLibrary({ targetTop += rowsInGroup * rowHeight; } } else { - const index = imageList.findIndex((img) => img.path === activePath); + const index = sortedImageList.findIndex((img) => img.path === activePath); if (index !== -1) { const rowIndex = Math.floor(index / columnCount); targetTop = rowIndex * rowHeight; @@ -1801,18 +2079,18 @@ export default function MainLibrary({ } }, [ activePath, - imageList, + sortedImageList, libraryViewMode, thumbnailSize, currentFolderPath, multiSelectedPaths.length, listHandle, - listColumnWidths.thumbnail, + resolvedListColumnWidths.thumbnail, ]); useEffect(() => { const exifEnabled = appSettings?.enableExifReading ?? true; - const exifSortKeys = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; + const exifSortKeys = EXIF_SORT_COLUMN_KEYS; const isCurrentSortExif = exifSortKeys.includes(sortCriteria.key); if (!exifEnabled && isCurrentSortExif) { @@ -2143,6 +2421,8 @@ export default function MainLibrary({ setSortCriteria={handleSortChange} sortCriteria={sortCriteria} sortOptions={sortOptions} + selectedSortFields={selectedSortFields} + onToggleSortField={handleToggleSortField} thumbnailSize={thumbnailSize} thumbnailAspectRatio={thumbnailAspectRatio} /> @@ -2169,7 +2449,7 @@ export default function MainLibrary({
- {imageList.length > 0 ? ( + {sortedImageList.length > 0 ? (
)}