@@ -89,6 +89,8 @@ import {
8989} from './components/panel/right/ExportImportProperties' ;
9090import {
9191 AppSettings ,
92+ AssociationInfo ,
93+ AssociatedPrimaryPreference ,
9294 BrushSettings ,
9395 FilterCriteria ,
9496 Invokes ,
@@ -115,6 +117,103 @@ import { ChannelConfig } from './components/adjustments/Curves';
115117
116118const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA' ; // local dev key
117119
120+ const JPEG_EXTENSIONS = new Set ( [ 'jpg' , 'jpeg' ] ) ;
121+
122+ type LibraryImageFile = ImageFile & { associatedPaths ?: Array < string > } ;
123+
124+ const getFileExtension = ( path : string ) => {
125+ const normalized = path . toLowerCase ( ) ;
126+ const lastDot = normalized . lastIndexOf ( '.' ) ;
127+ if ( lastDot === - 1 ) {
128+ return '' ;
129+ }
130+ return normalized . substring ( lastDot + 1 ) ;
131+ } ;
132+
133+ const getStemKey = ( path : string ) => {
134+ const normalized = path . replace ( / \\ / g, '/' ) ;
135+ const lastDot = normalized . lastIndexOf ( '.' ) ;
136+ if ( lastDot === - 1 ) {
137+ return normalized ;
138+ }
139+ return normalized . substring ( 0 , lastDot ) ;
140+ } ;
141+
142+ const buildAssociations = (
143+ images : Array < ImageFile > ,
144+ supportedTypes : SupportedTypes | null ,
145+ preferredType : AssociatedPrimaryPreference ,
146+ ) => {
147+ const associationMap : Record < string , AssociationInfo > = { } ;
148+ const groupedList : Array < LibraryImageFile > = [ ] ;
149+ const groups = new Map <
150+ string ,
151+ { variants : Array < ImageFile > ; jpeg ?: ImageFile ; raw ?: ImageFile }
152+ > ( ) ;
153+ const rawExtensions = new Set ( ( supportedTypes ?. raw || [ ] ) . map ( ( ext ) => ext . toLowerCase ( ) ) ) ;
154+
155+ images . forEach ( ( image ) => {
156+ const stem = getStemKey ( image . path ) ;
157+ let bucket = groups . get ( stem ) ;
158+ if ( ! bucket ) {
159+ bucket = { variants : [ ] } ;
160+ groups . set ( stem , bucket ) ;
161+ }
162+ bucket . variants . push ( image ) ;
163+ const ext = getFileExtension ( image . path ) ;
164+ if ( JPEG_EXTENSIONS . has ( ext ) ) {
165+ bucket . jpeg = image ;
166+ } else if ( rawExtensions . has ( ext ) && ! bucket . raw ) {
167+ bucket . raw = image ;
168+ }
169+ } ) ;
170+
171+ groups . forEach ( ( bucket ) => {
172+ if ( bucket . variants . length === 0 ) {
173+ return ;
174+ }
175+ const pickPrimary = ( ) => {
176+ const jpegCandidate = bucket . jpeg ;
177+ const rawCandidate = bucket . raw ;
178+ const fallback = bucket . variants [ 0 ] ;
179+ switch ( preferredType ) {
180+ case 'raw' :
181+ return rawCandidate || jpegCandidate || fallback ;
182+ case 'jpeg' :
183+ return jpegCandidate || rawCandidate || fallback ;
184+ case 'auto' :
185+ default :
186+ return jpegCandidate || rawCandidate || fallback ;
187+ }
188+ } ;
189+ const primary = pickPrimary ( ) ;
190+ const variantPaths = bucket . variants . map ( ( variant ) => variant . path ) ;
191+ const info : AssociationInfo = {
192+ primaryPath : primary . path ,
193+ variantPaths,
194+ jpegPath : bucket . jpeg ?. path ,
195+ rawPath : bucket . raw ?. path ,
196+ } ;
197+ bucket . variants . forEach ( ( variant ) => {
198+ associationMap [ variant . path ] = info ;
199+ } ) ;
200+ groupedList . push ( { ...primary , associatedPaths : variantPaths } ) ;
201+ } ) ;
202+
203+ return { associationMap, groupedList } ;
204+ } ;
205+
206+ const getVariantLabel = ( path : string , supportedTypes : SupportedTypes | null ) => {
207+ const ext = getFileExtension ( path ) ;
208+ if ( JPEG_EXTENSIONS . has ( ext ) ) {
209+ return 'JPEG' ;
210+ }
211+ if ( supportedTypes ?. raw ?. some ( ( rawExt ) => rawExt . toLowerCase ( ) === ext ) ) {
212+ return 'RAW' ;
213+ }
214+ return ext ? ext . toUpperCase ( ) : 'FILE' ;
215+ } ;
216+
118217interface CollapsibleSectionsState {
119218 basic : boolean ;
120219 color : boolean ;
@@ -372,11 +471,25 @@ function App() {
372471 progress : { current : 0 , total : 0 } ,
373472 status : Status . Idle ,
374473 } ) ;
474+ const [ groupAssociatedFiles , setGroupAssociatedFiles ] = useState ( true ) ;
475+ const [ preferredAssociatedType , setPreferredAssociatedType ] = useState < AssociatedPrimaryPreference > ( 'jpeg' ) ;
375476
376477 useEffect ( ( ) => {
377478 currentFolderPathRef . current = currentFolderPath ;
378479 } , [ currentFolderPath ] ) ;
379480
481+ const associationData = useMemo (
482+ ( ) => buildAssociations ( imageList , supportedTypes , preferredAssociatedType ) ,
483+ [ imageList , supportedTypes , preferredAssociatedType ] ,
484+ ) ;
485+ const associationsByPath = associationData . associationMap ;
486+ const groupedImageList = associationData . groupedList ;
487+ const isGroupingEnabled = groupAssociatedFiles && filterCriteria . rawStatus === RawStatus . All ;
488+ const visibleImageList = useMemo (
489+ ( ) => ( isGroupingEnabled ? groupedImageList : imageList ) ,
490+ [ isGroupingEnabled , groupedImageList , imageList ] ,
491+ ) ;
492+
380493 useEffect ( ( ) => {
381494 if ( ! isCopied ) {
382495 return ;
@@ -818,7 +931,7 @@ function App() {
818931 } ;
819932
820933 const sortedImageList = useMemo ( ( ) => {
821- const filteredList = imageList . filter ( ( image ) => {
934+ const filteredList = visibleImageList . filter ( ( image ) => {
822935 if ( filterCriteria . rating > 0 ) {
823936 const rating = imageRatings [ image . path ] || 0 ;
824937 if ( filterCriteria . rating === 5 ) {
@@ -972,7 +1085,25 @@ function App() {
9721085 return order === SortDirection . Ascending ? comparison : - comparison ;
9731086 } ) ;
9741087 return list ;
975- } , [ imageList , sortCriteria , imageRatings , filterCriteria , supportedTypes , searchQuery , appSettings ] ) ;
1088+ } , [ visibleImageList , sortCriteria , imageRatings , filterCriteria , supportedTypes , searchQuery , appSettings ] ) ;
1089+
1090+ const variantOptions = useMemo ( ( ) => {
1091+ if ( ! selectedImage ) {
1092+ return [ ] ;
1093+ }
1094+ const association = associationsByPath [ selectedImage . path ] ;
1095+ if ( ! association || association . variantPaths . length < 2 ) {
1096+ return [ ] ;
1097+ }
1098+ return association . variantPaths . map ( ( path ) => ( {
1099+ path,
1100+ label : getVariantLabel ( path , supportedTypes ) ,
1101+ } ) ) ;
1102+ } , [ selectedImage ?. path , associationsByPath , supportedTypes ] ) ;
1103+
1104+ const filmstripActivePath = selectedImage
1105+ ? associationsByPath [ selectedImage . path ] ?. primaryPath || selectedImage . path
1106+ : null ;
9761107
9771108 const applyAdjustments = useCallback (
9781109 debounce ( ( currentAdjustments ) => {
@@ -1140,6 +1271,16 @@ function App() {
11401271 if ( settings ?. thumbnailAspectRatio ) {
11411272 setThumbnailAspectRatio ( settings . thumbnailAspectRatio ) ;
11421273 }
1274+ if ( settings ?. groupAssociatedFiles !== undefined ) {
1275+ setGroupAssociatedFiles ( settings . groupAssociatedFiles ) ;
1276+ } else {
1277+ setGroupAssociatedFiles ( true ) ;
1278+ }
1279+ if ( settings ?. preferredAssociatedType ) {
1280+ setPreferredAssociatedType ( settings . preferredAssociatedType ) ;
1281+ } else {
1282+ setPreferredAssociatedType ( 'jpeg' ) ;
1283+ }
11431284 if ( settings ?. activeTreeSection ) {
11441285 setActiveTreeSection ( settings . activeTreeSection ) ;
11451286 }
@@ -1174,6 +1315,14 @@ function App() {
11741315 setIsWaveformVisible ( ( prev : boolean ) => ! prev ) ;
11751316 } , [ ] ) ;
11761317
1318+ const handleGroupAssociationsChange = useCallback ( ( value : boolean ) => {
1319+ setGroupAssociatedFiles ( value ) ;
1320+ } , [ ] ) ;
1321+
1322+ const handlePreferredAssociatedTypeChange = useCallback ( ( value : AssociatedPrimaryPreference ) => {
1323+ setPreferredAssociatedType ( value ) ;
1324+ } , [ ] ) ;
1325+
11771326 useEffect ( ( ) => {
11781327 if ( isInitialMount . current || ! appSettings ) {
11791328 return ;
@@ -1216,6 +1365,24 @@ function App() {
12161365 }
12171366 } , [ filterCriteria , appSettings , handleSettingsChange ] ) ;
12181367
1368+ useEffect ( ( ) => {
1369+ if ( isInitialMount . current || ! appSettings ) {
1370+ return ;
1371+ }
1372+ if ( appSettings . groupAssociatedFiles !== groupAssociatedFiles ) {
1373+ handleSettingsChange ( { ...appSettings , groupAssociatedFiles } ) ;
1374+ }
1375+ } , [ groupAssociatedFiles , appSettings , handleSettingsChange ] ) ;
1376+
1377+ useEffect ( ( ) => {
1378+ if ( isInitialMount . current || ! appSettings ) {
1379+ return ;
1380+ }
1381+ if ( appSettings . preferredAssociatedType !== preferredAssociatedType ) {
1382+ handleSettingsChange ( { ...appSettings , preferredAssociatedType } ) ;
1383+ }
1384+ } , [ preferredAssociatedType , appSettings , handleSettingsChange ] ) ;
1385+
12191386 useEffect ( ( ) => {
12201387 if ( appSettings ?. adaptiveEditorTheme && selectedImage && finalPreviewUrl ) {
12211388 generatePaletteFromImage ( finalPreviewUrl )
@@ -1467,6 +1634,10 @@ function App() {
14671634
14681635 const handleBackToLibrary = useCallback ( ( ) => {
14691636 const lastActivePath = selectedImage ?. path ?? null ;
1637+ const primaryPath =
1638+ lastActivePath && associationsByPath [ lastActivePath ]
1639+ ? associationsByPath [ lastActivePath ] . primaryPath
1640+ : lastActivePath ;
14701641 setSelectedImage ( null ) ;
14711642 setFinalPreviewUrl ( null ) ;
14721643 setUncroppedAdjustedPreviewUrl ( null ) ;
@@ -1477,8 +1648,9 @@ function App() {
14771648 setActiveMaskContainerId ( null ) ;
14781649 setActiveAiPatchContainerId ( null ) ;
14791650 setActiveAiSubMaskId ( null ) ;
1480- setLibraryActivePath ( lastActivePath ) ;
1481- } , [ selectedImage ?. path ] ) ;
1651+ setLibraryActivePath ( primaryPath ) ;
1652+ setMultiSelectedPaths ( primaryPath ? [ primaryPath ] : [ ] ) ;
1653+ } , [ associationsByPath , selectedImage ?. path ] ) ;
14821654
14831655 const executeDelete = useCallback (
14841656 async ( pathsToDelete : Array < string > , options = { includeAssociated : false } ) => {
@@ -1970,6 +2142,16 @@ function App() {
19702142 [ selectedImage ?. path , applyAdjustments , debouncedSave , thumbnails , resetAdjustmentsHistory ] ,
19712143 ) ;
19722144
2145+ const handleVariantSelect = useCallback (
2146+ ( path : string ) => {
2147+ if ( selectedImage ?. path === path ) {
2148+ return ;
2149+ }
2150+ handleImageSelect ( path ) ;
2151+ } ,
2152+ [ handleImageSelect , selectedImage ?. path ] ,
2153+ ) ;
2154+
19732155 useKeyboardShortcuts ( {
19742156 activeAiPatchContainerId,
19752157 activeAiSubMaskId,
@@ -2000,6 +2182,7 @@ function App() {
20002182 multiSelectedPaths,
20012183 redo,
20022184 selectedImage,
2185+ selectedDisplayPath : filmstripActivePath ,
20032186 setActiveAiSubMaskId,
20042187 setActiveMaskContainerId,
20052188 setActiveMaskId,
@@ -2807,12 +2990,8 @@ function App() {
28072990 const stitchLabel = `Stitch Panorama` ;
28082991
28092992 const hasAssociatedFiles = finalSelection . some ( ( selectedPath ) => {
2810- const lastDotIndex = selectedPath . lastIndexOf ( '.' ) ;
2811- if ( lastDotIndex === - 1 ) return false ;
2812- const basePath = selectedPath . substring ( 0 , lastDotIndex ) ;
2813- return imageList . some (
2814- ( image ) => image . path . startsWith ( basePath + '.' ) && image . path !== selectedPath ,
2815- ) ;
2993+ const association = associationsByPath [ selectedPath ] ;
2994+ return association && association . variantPaths . length > 1 ;
28162995 } ) ;
28172996
28182997 const deleteOption = {
@@ -3301,12 +3480,15 @@ function App() {
33013480 isFullResolution = { isFullResolution }
33023481 fullResolutionUrl = { fullResolutionUrl }
33033482 isLoadingFullRes = { isLoadingFullRes }
3483+ variantOptions = { variantOptions }
3484+ onVariantSelect = { handleVariantSelect }
33043485 />
33053486 < Resizer
33063487 direction = { Orientation . Horizontal }
33073488 onMouseDown = { createResizeHandler ( setBottomPanelHeight , bottomPanelHeight ) }
33083489 />
33093490 < BottomBar
3491+ activeDisplayPath = { filmstripActivePath }
33103492 filmstripHeight = { bottomPanelHeight }
33113493 imageList = { sortedImageList }
33123494 imageRatings = { imageRatings }
@@ -3520,6 +3702,10 @@ function App() {
35203702 thumbnails = { thumbnails }
35213703 thumbnailSize = { thumbnailSize }
35223704 onNavigateToCommunity = { ( ) => setActiveView ( 'community' ) }
3705+ groupAssociatedFiles = { groupAssociatedFiles }
3706+ preferredAssociatedType = { preferredAssociatedType }
3707+ onGroupAssociationsChange = { handleGroupAssociationsChange }
3708+ onPreferredAssociationTypeChange = { handlePreferredAssociatedTypeChange }
35233709 />
35243710 ) }
35253711 { rootPath && (
@@ -3714,4 +3900,4 @@ const AppWrapper = () => (
37143900 </ ClerkProvider >
37153901) ;
37163902
3717- export default AppWrapper ;
3903+ export default AppWrapper ;
0 commit comments