diff --git a/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx b/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx index ae03844a78..cb7f3658e8 100644 --- a/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx +++ b/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx @@ -6,16 +6,15 @@ import type { UploadFieldClient, } from 'payload' +import { isImage } from 'payload/shared' import React from 'react' -import { Thumbnail } from '../../../../Thumbnail/index.js' import './index.scss' +import { getBestFitFromSizes } from '../../../../../utilities/getBestFitFromSizes.js' +import { Thumbnail } from '../../../../Thumbnail/index.js' const baseClass = 'file' -const targetThumbnailSizeMin = 40 -const targetThumbnailSizeMax = 180 - export interface FileCellProps extends DefaultCellComponentProps { readonly collectionConfig: ClientCollectionConfig @@ -33,46 +32,13 @@ export const FileCell: React.FC = ({ if (previewAllowed) { let fileSrc: string | undefined = rowData?.thumbnailURL ?? rowData?.url - if ( - rowData?.url && - !rowData?.thumbnailURL && - typeof rowData?.mimeType === 'string' && - rowData?.mimeType.startsWith('image') && - rowData?.sizes - ) { - const sizes = Object.values<{ url?: string; width?: number }>(rowData.sizes) - - const bestFit = sizes.reduce( - (closest, current) => { - if (!current.width || current.width < targetThumbnailSizeMin) { - return closest - } - - if (current.width >= targetThumbnailSizeMin && current.width <= targetThumbnailSizeMax) { - return !closest.width || - current.width < closest.width || - closest.width < targetThumbnailSizeMin || - closest.width > targetThumbnailSizeMax - ? current - : closest - } - - if ( - !closest.width || - (!closest.original && - closest.width < targetThumbnailSizeMin && - current.width > closest.width) || - (closest.width > targetThumbnailSizeMax && current.width < closest.width) - ) { - return current - } - - return closest - }, - { original: true, url: rowData?.url, width: rowData?.width }, - ) - - fileSrc = bestFit.url || fileSrc + if (isImage(rowData?.mimeType)) { + fileSrc = getBestFitFromSizes({ + sizes: rowData?.sizes, + thumbnailURL: rowData?.thumbnailURL, + url: rowData?.url, + width: rowData?.width, + }) } return ( diff --git a/packages/ui/src/fields/Upload/HasMany/index.tsx b/packages/ui/src/fields/Upload/HasMany/index.tsx index 7f3972b5de..30c68b780c 100644 --- a/packages/ui/src/fields/Upload/HasMany/index.tsx +++ b/packages/ui/src/fields/Upload/HasMany/index.tsx @@ -11,10 +11,14 @@ import { UploadCard } from '../UploadCard/index.js' const baseClass = 'upload upload--has-many' -import type { ReloadDoc } from '../types.js' +import { isImage } from 'payload/shared' import './index.scss' +import type { ReloadDoc } from '../types.js' + +import { getBestFitFromSizes } from '../../../utilities/getBestFitFromSizes.js' + type Props = { readonly className?: string readonly displayPreview?: boolean @@ -96,6 +100,15 @@ export function UploadComponentHasMany(props: Props) { } } + if (isImage(value.mimeType)) { + thumbnailSrc = getBestFitFromSizes({ + sizes: value.sizes, + thumbnailURL: thumbnailSrc, + url: src, + width: value.width, + }) + } + return ( {(draggableSortableItemProps) => ( @@ -137,7 +150,7 @@ export function UploadComponentHasMany(props: Props) { onRemove={() => removeItem(index)} reloadDoc={reloadDoc} src={src} - thumbnailSrc={thumbnailSrc || src} + thumbnailSrc={thumbnailSrc} withMeta={false} x={value?.width as number} y={value?.height as number} diff --git a/packages/ui/src/fields/Upload/HasOne/index.tsx b/packages/ui/src/fields/Upload/HasOne/index.tsx index b2e34c7e8a..b8bec14b11 100644 --- a/packages/ui/src/fields/Upload/HasOne/index.tsx +++ b/packages/ui/src/fields/Upload/HasOne/index.tsx @@ -2,13 +2,15 @@ import type { JsonObject } from 'payload' +import { isImage } from 'payload/shared' import React from 'react' import type { ReloadDoc } from '../types.js' +import { getBestFitFromSizes } from '../../../utilities/getBestFitFromSizes.js' +import './index.scss' import { RelationshipContent } from '../RelationshipContent/index.js' import { UploadCard } from '../UploadCard/index.js' -import './index.scss' const baseClass = 'upload upload--has-one' @@ -49,6 +51,15 @@ export function UploadComponentHasOne(props: Props) { } } + if (isImage(value.mimeType)) { + thumbnailSrc = getBestFitFromSizes({ + sizes: value.sizes, + thumbnailURL: thumbnailSrc, + url: src, + width: value.width, + }) + } + return ( diff --git a/packages/ui/src/fields/Upload/RelationshipContent/index.tsx b/packages/ui/src/fields/Upload/RelationshipContent/index.tsx index 875acbc236..5ee765b45b 100644 --- a/packages/ui/src/fields/Upload/RelationshipContent/index.tsx +++ b/packages/ui/src/fields/Upload/RelationshipContent/index.tsx @@ -2,7 +2,7 @@ import type { TypeWithID } from 'payload' -import { formatFilesize } from 'payload/shared' +import { formatFilesize, isImage } from 'payload/shared' import React from 'react' import type { ReloadDoc } from '../types.js' @@ -99,7 +99,7 @@ export function RelationshipContent(props: Props) { alt={alt} className={`${baseClass}__thumbnail`} filename={filename} - fileSrc={thumbnailSrc} + fileSrc={isImage(mimeType) && thumbnailSrc} size="small" /> )} diff --git a/packages/ui/src/utilities/getBestFitFromSizes.ts b/packages/ui/src/utilities/getBestFitFromSizes.ts new file mode 100644 index 0000000000..5a2c7633c3 --- /dev/null +++ b/packages/ui/src/utilities/getBestFitFromSizes.ts @@ -0,0 +1,70 @@ +/** + * Takes image sizes and a target range and returns the url of the image within that range. + * If no images fit within the range, it selects the next smallest adequate image, the original, + * or the largest smaller image if no better fit exists. + * + * @param sizes The given FileSizes. + * @param targetSizeMax The ideal image maximum width. Defaults to 180. + * @param targetSizeMin The ideal image minimum width. Defaults to 40. + * @param thumbnailURL The thumbnail url set in config. If passed a url, will return early with it. + * @param url The url of the original file. + * @param width The width of the original file. + * @returns A url of the best fit file. + */ +export const getBestFitFromSizes = ({ + sizes, + targetSizeMax = 180, + targetSizeMin = 40, + thumbnailURL, + url, + width, +}: { + sizes?: Record + targetSizeMax?: number + targetSizeMin?: number + thumbnailURL?: string + url: string + width?: number +}) => { + if (thumbnailURL) { + return thumbnailURL + } + + if (!sizes) { + return url + } + + const bestFit = Object.values(sizes).reduce<{ + original?: boolean + url?: string + width?: number + }>( + (closest, current) => { + if (!current.width || current.width < targetSizeMin) { + return closest + } + + if (current.width >= targetSizeMin && current.width <= targetSizeMax) { + return !closest.width || + current.width < closest.width || + closest.width < targetSizeMin || + closest.width > targetSizeMax + ? current + : closest + } + + if ( + !closest.width || + (!closest.original && closest.width < targetSizeMin && current.width > closest.width) || + (closest.width > targetSizeMax && current.width < closest.width) + ) { + return current + } + + return closest + }, + { original: true, url, width }, + ) + + return bestFit.url || url +} diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 183591a7a7..ab0199c695 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -735,6 +735,31 @@ export default buildConfigWithDefaults({ }, ], }, + { + slug: 'best-fit', + fields: [ + { + name: 'withAdminThumbnail', + type: 'upload', + relationTo: 'admin-thumbnail-function', + }, + { + name: 'withinRange', + type: 'upload', + relationTo: enlargeSlug, + }, + { + name: 'nextSmallestOutOfRange', + type: 'upload', + relationTo: 'focal-only', + }, + { + name: 'original', + type: 'upload', + relationTo: 'focal-only', + }, + ], + }, ], onInit: async (payload) => { const uploadsDir = path.resolve(dirname, './media') diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 9d02038879..6b9a2e5157 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -66,6 +66,7 @@ let uploadsOne: AdminUrlUtil let uploadsTwo: AdminUrlUtil let customUploadFieldURL: AdminUrlUtil let hideFileInputOnCreateURL: AdminUrlUtil +let bestFitURL: AdminUrlUtil let consoleErrorsFromPage: string[] = [] let collectErrorsFromPage: () => boolean let stopCollectingErrorsFromPage: () => boolean @@ -99,6 +100,7 @@ describe('Uploads', () => { uploadsTwo = new AdminUrlUtil(serverURL, 'uploads-2') customUploadFieldURL = new AdminUrlUtil(serverURL, customUploadFieldSlug) hideFileInputOnCreateURL = new AdminUrlUtil(serverURL, hideFileInputOnCreateSlug) + bestFitURL = new AdminUrlUtil(serverURL, 'best-fit') const context = await browser.newContext() page = await context.newPage() @@ -1349,4 +1351,53 @@ describe('Uploads', () => { await expect(page.locator('.file-field .file-details__remove')).toBeHidden() }) + + describe('imageSizes best fit', () => { + test('should select adminThumbnail if one exists', async () => { + await page.goto(bestFitURL.create) + await page.locator('#field-withAdminThumbnail button.upload__listToggler').click() + await page.locator('tr.row-1 td.cell-filename button.default-cell__first-cell').click() + const thumbnail = page.locator('#field-withAdminThumbnail div.thumbnail > img') + await expect(thumbnail).toHaveAttribute( + 'src', + 'https://payloadcms.com/images/universal-truth.jpg', + ) + }) + + test('should select an image within target range', async () => { + await page.goto(bestFitURL.create) + await page.locator('#field-withinRange button.upload__createNewToggler').click() + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByText('Select a file').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles(path.join(dirname, 'test-image.jpg')) + await page.locator('dialog button#action-save').click() + const thumbnail = page.locator('#field-withinRange div.thumbnail > img') + await expect(thumbnail).toHaveAttribute('src', '/api/enlarge/file/test-image-180x50.jpg') + }) + + test('should select next smallest image outside of range but smaller than original', async () => { + await page.goto(bestFitURL.create) + await page.locator('#field-nextSmallestOutOfRange button.upload__createNewToggler').click() + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByText('Select a file').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles(path.join(dirname, 'test-image.jpg')) + await page.locator('dialog button#action-save').click() + const thumbnail = page.locator('#field-nextSmallestOutOfRange div.thumbnail > img') + await expect(thumbnail).toHaveAttribute('src', '/api/focal-only/file/test-image-400x300.jpg') + }) + + test('should select original if smaller than next available size', async () => { + await page.goto(bestFitURL.create) + await page.locator('#field-original button.upload__createNewToggler').click() + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByText('Select a file').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles(path.join(dirname, 'small.png')) + await page.locator('dialog button#action-save').click() + const thumbnail = page.locator('#field-original div.thumbnail > img') + await expect(thumbnail).toHaveAttribute('src', '/api/focal-only/file/small.png') + }) + }) }) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index bd0bc2592e..2dc7d55528 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -101,6 +101,7 @@ export interface Config { 'media-without-relation-preview': MediaWithoutRelationPreview; 'relation-preview': RelationPreview; 'hide-file-input-on-create': HideFileInputOnCreate; + 'best-fit': BestFit; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -142,6 +143,7 @@ export interface Config { 'media-without-relation-preview': MediaWithoutRelationPreviewSelect | MediaWithoutRelationPreviewSelect; 'relation-preview': RelationPreviewSelect | RelationPreviewSelect; 'hide-file-input-on-create': HideFileInputOnCreateSelect | HideFileInputOnCreateSelect; + 'best-fit': BestFitSelect | BestFitSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -1260,6 +1262,19 @@ export interface RelationPreview { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "best-fit". + */ +export interface BestFit { + id: string; + withAdminThumbnail?: (string | null) | AdminThumbnailFunction; + withinRange?: (string | null) | Enlarge; + nextSmallestOutOfRange?: (string | null) | FocalOnly; + original?: (string | null) | FocalOnly; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -1420,6 +1435,10 @@ export interface PayloadLockedDocument { relationTo: 'hide-file-input-on-create'; value: string | HideFileInputOnCreate; } | null) + | ({ + relationTo: 'best-fit'; + value: string | BestFit; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -2632,6 +2651,18 @@ export interface HideFileInputOnCreateSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "best-fit_select". + */ +export interface BestFitSelect { + withAdminThumbnail?: T; + withinRange?: T; + nextSmallestOutOfRange?: T; + original?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select".