feat: store focal point on uploads (#6436)

Store focal point data on uploads as `focalX` and `focalY`

Addresses https://github.com/payloadcms/payload/discussions/4082

Mirrors #6364 for beta branch.
This commit is contained in:
Elliot DeNolf
2024-05-20 15:57:52 -04:00
committed by GitHub
parent fa7cc376d1
commit 36fda30c61
20 changed files with 390 additions and 127 deletions

View File

@@ -152,7 +152,7 @@ type FetchAPIFileUpload = (args: {
request: Request request: Request
}) => Promise<FetchAPIFileUploadResponse> }) => Promise<FetchAPIFileUploadResponse>
export const fetchAPIFileUpload: FetchAPIFileUpload = async ({ options, request }) => { export const fetchAPIFileUpload: FetchAPIFileUpload = async ({ options, request }) => {
const uploadOptions = { ...DEFAULT_OPTIONS, ...options } const uploadOptions: FetchAPIFileUploadOptions = { ...DEFAULT_OPTIONS, ...options }
if (!isEligibleRequest(request)) { if (!isEligibleRequest(request)) {
debugLog(uploadOptions, 'Request is not eligible for file upload!') debugLog(uploadOptions, 'Request is not eligible for file upload!')
return { return {

View File

@@ -121,6 +121,7 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
collection, collection,
config, config,
data, data,
operation: 'create',
overwriteExistingFiles, overwriteExistingFiles,
req, req,
throwOnMissingFile: throwOnMissingFile:

View File

@@ -156,6 +156,7 @@ export const updateOperation = async <TSlug extends keyof GeneratedTypes['collec
collection, collection,
config, config,
data: bulkUpdateData, data: bulkUpdateData,
operation: 'update',
overwriteExistingFiles, overwriteExistingFiles,
req, req,
throwOnMissingFile: false, throwOnMissingFile: false,

View File

@@ -147,6 +147,7 @@ export const updateByIDOperation = async <TSlug extends keyof GeneratedTypes['co
collection, collection,
config, config,
data, data,
operation: 'update',
overwriteExistingFiles, overwriteExistingFiles,
req, req,
throwOnMissingFile: false, throwOnMissingFile: false,

View File

@@ -12,7 +12,7 @@ export { traverseFields as beforeValidateTraverseFields } from '../fields/hooks/
export { formatFilesize } from '../uploads/formatFilesize.js' export { formatFilesize } from '../uploads/formatFilesize.js'
export { default as isImage } from '../uploads/isImage.js' export { isImage } from '../uploads/isImage.js'
export { combineMerge } from '../utilities/combineMerge.js' export { combineMerge } from '../utilities/combineMerge.js'
export { export {

View File

@@ -7,19 +7,6 @@ import type { GeneratedTypes } from '../index.js'
import type { validOperators } from './constants.js' import type { validOperators } from './constants.js'
export type { Payload as Payload } from '../index.js' export type { Payload as Payload } from '../index.js'
export type UploadEdits = {
crop?: {
height?: number
width?: number
x?: number
y?: number
}
focalPoint?: {
x?: number
y?: number
}
}
export type CustomPayloadRequestProperties = { export type CustomPayloadRequestProperties = {
context: RequestContext context: RequestContext
/** The locale that should be used for a field when it is not translated to the requested locale */ /** The locale that should be used for a field when it is not translated to the requested locale */

View File

@@ -1,3 +1,3 @@
export default function canResizeImage(mimeType: string): boolean { export function canResizeImage(mimeType: string): boolean {
return ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff'].indexOf(mimeType) > -1 return ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff'].indexOf(mimeType) > -1
} }

View File

@@ -2,7 +2,7 @@ export const percentToPixel = (value, dimension) => {
return Math.floor((parseFloat(value) / 100) * dimension) return Math.floor((parseFloat(value) / 100) * dimension)
} }
export default async function cropImage({ cropData, dimensions, file, sharp }) { export async function cropImage({ cropData, dimensions, file, sharp }) {
try { try {
const { height, width, x, y } = cropData const { height, width, x, y } = cropData

View File

@@ -9,22 +9,24 @@ import sanitize from 'sanitize-filename'
import type { Collection } from '../collections/config/types.js' import type { Collection } from '../collections/config/types.js'
import type { SanitizedConfig } from '../config/types.js' import type { SanitizedConfig } from '../config/types.js'
import type { PayloadRequestWithData } from '../types/index.js' import type { PayloadRequestWithData } from '../types/index.js'
import type { FileData, FileToSave, ProbedImageSize } from './types.js' import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js'
import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js' import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js'
import canResizeImage from './canResizeImage.js' import { canResizeImage } from './canResizeImage.js'
import cropImage from './cropImage.js' import { cropImage } from './cropImage.js'
import { getExternalFile } from './getExternalFile.js' import { getExternalFile } from './getExternalFile.js'
import { getFileByPath } from './getFileByPath.js' import { getFileByPath } from './getFileByPath.js'
import { getImageSize } from './getImageSize.js' import { getImageSize } from './getImageSize.js'
import getSafeFileName from './getSafeFilename.js' import { getSafeFileName } from './getSafeFilename.js'
import resizeAndTransformImageSizes from './imageResizer.js' import { resizeAndTransformImageSizes } from './imageResizer.js'
import isImage from './isImage.js' import { isImage } from './isImage.js'
type Args<T> = { type Args<T> = {
collection: Collection collection: Collection
config: SanitizedConfig config: SanitizedConfig
data: T data: T
operation: 'create' | 'update'
originalDoc?: T
overwriteExistingFiles?: boolean overwriteExistingFiles?: boolean
req: PayloadRequestWithData req: PayloadRequestWithData
throwOnMissingFile?: boolean throwOnMissingFile?: boolean
@@ -38,6 +40,8 @@ type Result<T> = Promise<{
export const generateFileData = async <T>({ export const generateFileData = async <T>({
collection: { config: collectionConfig }, collection: { config: collectionConfig },
data, data,
operation,
originalDoc,
overwriteExistingFiles, overwriteExistingFiles,
req, req,
throwOnMissingFile, throwOnMissingFile,
@@ -53,10 +57,22 @@ export const generateFileData = async <T>({
let file = req.file let file = req.file
const uploadEdits = req.query['uploadEdits'] || {} const uploadEdits = parseUploadEditsFromReqOrIncomingData({
data,
operation,
originalDoc,
req,
})
const { disableLocalStorage, formatOptions, imageSizes, resizeOptions, staticDir, trimOptions } = const {
collectionConfig.upload disableLocalStorage,
focalPoint: focalPointEnabled = true,
formatOptions,
imageSizes,
resizeOptions,
staticDir,
trimOptions,
} = collectionConfig.upload
const staticPath = staticDir const staticPath = staticDir
@@ -228,9 +244,9 @@ export const generateFileData = async <T>({
} }
} }
if (Array.isArray(imageSizes) && fileSupportsResize && sharp) { if (fileSupportsResize && (Array.isArray(imageSizes) || focalPointEnabled !== false)) {
req.payloadUploadSizes = {} req.payloadUploadSizes = {}
const { sizeData, sizesToSave } = await resizeAndTransformImageSizes({ const { focalPoint, sizeData, sizesToSave } = await resizeAndTransformImageSizes({
config: collectionConfig, config: collectionConfig,
dimensions: !cropData dimensions: !cropData
? dimensions ? dimensions
@@ -245,13 +261,16 @@ export const generateFileData = async <T>({
savedFilename: fsSafeName || file.name, savedFilename: fsSafeName || file.name,
sharp, sharp,
staticPath, staticPath,
uploadEdits,
}) })
fileData.sizes = sizeData fileData.sizes = sizeData
fileData.focalX = focalPoint?.x
fileData.focalY = focalPoint?.y
filesToSave.push(...sizesToSave) filesToSave.push(...sizesToSave)
} }
} catch (err) { } catch (err) {
console.error(err) req.payload.logger.error(err)
throw new FileUploadError(req.t) throw new FileUploadError(req.t)
} }
@@ -265,3 +284,50 @@ export const generateFileData = async <T>({
files: filesToSave, files: filesToSave,
} }
} }
/**
* Parse upload edits from req or incoming data
*/
function parseUploadEditsFromReqOrIncomingData(args: {
data: unknown
operation: 'create' | 'update'
originalDoc: unknown
req: PayloadRequestWithData
}): UploadEdits {
const { data, operation, originalDoc, req } = args
// Get intended focal point change from query string or incoming data
const {
uploadEdits = {},
}: {
uploadEdits?: UploadEdits
} = req.query || {}
if (uploadEdits.focalPoint) return uploadEdits
const incomingData = data as FileData
const origDoc = originalDoc as FileData
// If no change in focal point, return undefined.
// This prevents a refocal operation triggered from admin, because it always sends the focal point.
if (origDoc && incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
return undefined
}
if (incomingData?.focalX && incomingData?.focalY) {
uploadEdits.focalPoint = {
x: incomingData.focalX,
y: incomingData.focalY,
}
return uploadEdits
}
// If no focal point is set, default to center
if (operation === 'create') {
uploadEdits.focalPoint = {
x: 50,
y: 50,
}
}
return uploadEdits
}

View File

@@ -149,6 +149,25 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
height, height,
] ]
// Add focal point fields if not disabled
if (
uploadOptions.focalPoint !== false ||
uploadOptions.imageSizes ||
uploadOptions.resizeOptions
) {
uploadFields = uploadFields.concat(
['focalX', 'focalY'].map((name) => {
return {
name,
type: 'number',
admin: {
hidden: true,
},
}
}),
)
}
if (uploadOptions.mimeTypes) { if (uploadOptions.mimeTypes) {
mimeType.validate = mimeTypeValidator(uploadOptions.mimeTypes) mimeType.validate = mimeTypeValidator(uploadOptions.mimeTypes)
} }

View File

@@ -29,7 +29,7 @@ type Args = {
staticPath: string staticPath: string
} }
async function getSafeFileName({ export async function getSafeFileName({
collectionSlug, collectionSlug,
desiredFilename, desiredFilename,
req, req,
@@ -51,5 +51,3 @@ async function getSafeFileName({
} }
return modifiedFilename return modifiedFilename
} }
export default getSafeFileName

View File

@@ -8,8 +8,15 @@ import sanitize from 'sanitize-filename'
import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { SharpDependency } from '../config/types.js' import type { SharpDependency } from '../config/types.js'
import type { PayloadRequestWithData, UploadEdits } from '../types/index.js' import type { PayloadRequestWithData } from '../types/index.js'
import type { FileSize, FileSizes, FileToSave, ImageSize, ProbedImageSize } from './types.js' import type {
FileSize,
FileSizes,
FileToSave,
ImageSize,
ProbedImageSize,
UploadEdits,
} from './types.js'
import { isNumber } from '../utilities/isNumber.js' import { isNumber } from '../utilities/isNumber.js'
import fileExists from './fileExists.js' import fileExists from './fileExists.js'
@@ -19,18 +26,16 @@ type ResizeArgs = {
dimensions: ProbedImageSize dimensions: ProbedImageSize
file: PayloadRequestWithData['file'] file: PayloadRequestWithData['file']
mimeType: string mimeType: string
req: PayloadRequestWithData & { req: PayloadRequestWithData
query?: {
uploadEdits?: UploadEdits
}
}
savedFilename: string savedFilename: string
sharp: SharpDependency sharp?: SharpDependency
staticPath: string staticPath: string
uploadEdits?: UploadEdits
} }
/** Result from resizing and transforming the requested image sizes */ /** Result from resizing and transforming the requested image sizes */
type ImageSizesResult = { type ImageSizesResult = {
focalPoint?: UploadEdits['focalPoint']
sizeData: FileSizes sizeData: FileSizes
sizesToSave: FileToSave[] sizesToSave: FileToSave[]
} }
@@ -71,6 +76,16 @@ const createImageName = (
extension: string, extension: string,
) => `${outputImageName}-${width}x${height}.${extension}` ) => `${outputImageName}-${width}x${height}.${extension}`
type CreateResultArgs = {
filename?: FileSize['filename']
filesize?: FileSize['filesize']
height?: FileSize['height']
mimeType?: FileSize['mimeType']
name: string
sizesToSave?: FileToSave[]
width?: FileSize['width']
}
/** /**
* Create the result object for the image resize operation based on the * Create the result object for the image resize operation based on the
* provided parameters. If the name is not provided, an empty result object * provided parameters. If the name is not provided, an empty result object
@@ -85,26 +100,28 @@ const createImageName = (
* @param sizesToSave - the sizes to save * @param sizesToSave - the sizes to save
* @returns the result object * @returns the result object
*/ */
const createResult = ( const createResult = ({
name: string, name,
filename: FileSize['filename'] = null, filename = null,
width: FileSize['width'] = null, filesize = null,
height: FileSize['height'] = null, height = null,
filesize: FileSize['filesize'] = null, mimeType = null,
mimeType: FileSize['mimeType'] = null, sizesToSave = [],
sizesToSave: FileToSave[] = [], width = null,
): ImageSizesResult => ({ }: CreateResultArgs): ImageSizesResult => {
sizeData: { return {
[name]: { sizeData: {
filename, [name]: {
filesize, filename,
height, filesize,
mimeType, height,
width, mimeType,
width,
},
}, },
}, sizesToSave,
sizesToSave, }
}) }
/** /**
* Check if the image needs to be resized according to the requested dimensions * Check if the image needs to be resized according to the requested dimensions
@@ -208,7 +225,7 @@ const sanitizeResizeConfig = (resizeConfig: ImageSize): ImageSize => {
* @param resizeConfig - the resize config * @param resizeConfig - the resize config
* @returns the result of the resize operation(s) * @returns the result of the resize operation(s)
*/ */
export default async function resizeAndTransformImageSizes({ export async function resizeAndTransformImageSizes({
config, config,
dimensions, dimensions,
file, file,
@@ -217,10 +234,27 @@ export default async function resizeAndTransformImageSizes({
savedFilename, savedFilename,
sharp, sharp,
staticPath, staticPath,
uploadEdits,
}: ResizeArgs): Promise<ImageSizesResult> { }: ResizeArgs): Promise<ImageSizesResult> {
const { imageSizes } = config.upload const { focalPoint: focalPointEnabled = true, imageSizes } = config.upload
// Noting to resize here so return as early as possible
if (!imageSizes) return { sizeData: {}, sizesToSave: [] } // Focal point adjustments
const incomingFocalPoint = uploadEdits.focalPoint
? {
x: isNumber(uploadEdits.focalPoint.x) ? Math.round(uploadEdits.focalPoint.x) : 50,
y: isNumber(uploadEdits.focalPoint.y) ? Math.round(uploadEdits.focalPoint.y) : 50,
}
: undefined
const defaultResult: ImageSizesResult = {
...(focalPointEnabled && incomingFocalPoint && { focalPoint: incomingFocalPoint }),
sizeData: {},
sizesToSave: [],
}
if (!imageSizes || !sharp) {
return defaultResult
}
const sharpBase = sharp(file.tempFilePath || file.data).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081 const sharpBase = sharp(file.tempFilePath || file.data).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
@@ -232,16 +266,13 @@ export default async function resizeAndTransformImageSizes({
// skipped COMPLETELY and thus will not be included in the resulting images. // skipped COMPLETELY and thus will not be included in the resulting images.
// All further format/trim options will thus be skipped as well. // All further format/trim options will thus be skipped as well.
if (preventResize(imageResizeConfig, dimensions)) { if (preventResize(imageResizeConfig, dimensions)) {
return createResult(imageResizeConfig.name) return createResult({ name: imageResizeConfig.name })
} }
const imageToResize = sharpBase.clone() const imageToResize = sharpBase.clone()
let resized = imageToResize let resized = imageToResize
if ( if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) {
req.query?.uploadEdits?.focalPoint &&
applyPayloadAdjustments(imageResizeConfig, dimensions)
) {
const { height: resizeHeight, width: resizeWidth } = imageResizeConfig const { height: resizeHeight, width: resizeWidth } = imageResizeConfig
const resizeAspectRatio = resizeWidth / resizeHeight const resizeAspectRatio = resizeWidth / resizeHeight
const originalAspectRatio = dimensions.width / dimensions.height const originalAspectRatio = dimensions.width / dimensions.height
@@ -254,27 +285,17 @@ export default async function resizeAndTransformImageSizes({
}) })
const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true }) const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true })
// Focal point adjustments
const focalPoint = {
x: isNumber(req.query.uploadEdits.focalPoint?.x)
? req.query.uploadEdits.focalPoint.x
: 50,
y: isNumber(req.query.uploadEdits.focalPoint?.y)
? req.query.uploadEdits.focalPoint.y
: 50,
}
const safeResizeWidth = resizeWidth ?? scaledImageInfo.width const safeResizeWidth = resizeWidth ?? scaledImageInfo.width
const maxOffsetX = scaledImageInfo.width - safeResizeWidth const maxOffsetX = scaledImageInfo.width - safeResizeWidth
const leftFocalEdge = Math.round( const leftFocalEdge = Math.round(
scaledImageInfo.width * (focalPoint.x / 100) - safeResizeWidth / 2, scaledImageInfo.width * (incomingFocalPoint.x / 100) - safeResizeWidth / 2,
) )
const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX) const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX)
const safeResizeHeight = resizeHeight ?? scaledImageInfo.height const safeResizeHeight = resizeHeight ?? scaledImageInfo.height
const maxOffsetY = scaledImageInfo.height - safeResizeHeight const maxOffsetY = scaledImageInfo.height - safeResizeHeight
const topFocalEdge = Math.round( const topFocalEdge = Math.round(
scaledImageInfo.height * (focalPoint.y / 100) - safeResizeHeight / 2, scaledImageInfo.height * (incomingFocalPoint.y / 100) - safeResizeHeight / 2,
) )
const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY) const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY)
@@ -306,7 +327,9 @@ export default async function resizeAndTransformImageSizes({
const sanitizedImage = getSanitizedImageData(savedFilename) const sanitizedImage = getSanitizedImageData(savedFilename)
req.payloadUploadSizes[imageResizeConfig.name] = bufferData if (req.payloadUploadSizes) {
req.payloadUploadSizes[imageResizeConfig.name] = bufferData
}
const mimeInfo = await fromBuffer(bufferData) const mimeInfo = await fromBuffer(bufferData)
@@ -327,15 +350,15 @@ export default async function resizeAndTransformImageSizes({
} }
const { height, size, width } = bufferInfo const { height, size, width } = bufferInfo
return createResult( return createResult({
imageResizeConfig.name, name: imageResizeConfig.name,
imageNameWithDimensions, filename: imageNameWithDimensions,
width, filesize: size,
height, height,
size, mimeType: mimeInfo?.mime || mimeType,
mimeInfo?.mime || mimeType, sizesToSave: [{ buffer: bufferData, path: imagePath }],
[{ buffer: bufferData, path: imagePath }], width,
) })
}), }),
) )
@@ -345,6 +368,6 @@ export default async function resizeAndTransformImageSizes({
acc.sizesToSave.push(...result.sizesToSave) acc.sizesToSave.push(...result.sizesToSave)
return acc return acc
}, },
{ sizeData: {}, sizesToSave: [] }, { ...defaultResult },
) )
} }

View File

@@ -1,4 +1,4 @@
export default function isImage(mimeType: string): boolean { export function isImage(mimeType: string): boolean {
return ( return (
['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif'].indexOf( ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif'].indexOf(
mimeType, mimeType,

View File

@@ -18,6 +18,8 @@ export type FileSizes = {
export type FileData = { export type FileData = {
filename: string filename: string
filesize: number filesize: number
focalX?: number
focalY?: number
height: number height: number
mimeType: string mimeType: string
sizes: FileSizes sizes: FileSizes
@@ -117,3 +119,16 @@ export type FileToSave = {
buffer: Buffer buffer: Buffer
path: string path: string
} }
export type UploadEdits = {
crop?: {
height?: number
width?: number
x?: number
y?: number
}
focalPoint?: {
x?: number
y?: number
}
}

View File

@@ -32,13 +32,12 @@ type FocalPosition = {
} }
export type EditUploadProps = { export type EditUploadProps = {
doc?: Data
fileName: string fileName: string
fileSrc: string fileSrc: string
imageCacheTag?: string imageCacheTag?: string
initialCrop?: CropType initialCrop?: CropType
initialFocalPoint?: FocalPosition initialFocalPoint?: FocalPosition
onSave?: ({ crop, pointPosition }: { crop: CropType; pointPosition: FocalPosition }) => void onSave?: ({ crop, focalPosition }: { crop: CropType; focalPosition: FocalPosition }) => void
showCrop?: boolean showCrop?: boolean
showFocalPoint?: boolean showFocalPoint?: boolean
} }
@@ -51,11 +50,6 @@ const defaultCrop: CropType = {
y: 0, y: 0,
} }
const defaultPointPosition: FocalPosition = {
x: 50,
y: 50,
}
export const EditUpload: React.FC<EditUploadProps> = ({ export const EditUpload: React.FC<EditUploadProps> = ({
fileName, fileName,
fileSrc, fileSrc,
@@ -76,8 +70,15 @@ export const EditUpload: React.FC<EditUploadProps> = ({
...initialCrop, ...initialCrop,
})) }))
const [pointPosition, setPointPosition] = useState<FocalPosition>(() => ({ const defaultFocalPosition: FocalPosition = {
...defaultPointPosition, x: 50,
y: 50,
}
console.log({ initialFocalPoint })
const [focalPosition, setFocalPosition] = useState<FocalPosition>(() => ({
...defaultFocalPosition,
...initialFocalPoint, ...initialFocalPoint,
})) }))
const [checkBounds, setCheckBounds] = useState<boolean>(false) const [checkBounds, setCheckBounds] = useState<boolean>(false)
@@ -103,10 +104,16 @@ export const EditUpload: React.FC<EditUploadProps> = ({
}) })
} }
const fineTuneFocalPoint = ({ coordinate, value }: { coordinate: 'x' | 'y'; value: string }) => { const fineTuneFocalPosition = ({
coordinate,
value,
}: {
coordinate: 'x' | 'y'
value: string
}) => {
const intValue = parseInt(value) const intValue = parseInt(value)
if (intValue >= 0 && intValue <= 100) { if (intValue >= 0 && intValue <= 100) {
setPointPosition((prevPosition) => ({ ...prevPosition, [coordinate]: intValue })) setFocalPosition((prevPosition) => ({ ...prevPosition, [coordinate]: intValue }))
} }
} }
@@ -114,13 +121,13 @@ export const EditUpload: React.FC<EditUploadProps> = ({
if (typeof onSave === 'function') if (typeof onSave === 'function')
onSave({ onSave({
crop, crop,
pointPosition, focalPosition,
}) })
closeModal(editDrawerSlug) closeModal(editDrawerSlug)
} }
const onDragEnd = React.useCallback(({ x, y }) => { const onDragEnd = React.useCallback(({ x, y }) => {
setPointPosition({ x, y }) setFocalPosition({ x, y })
setCheckBounds(false) setCheckBounds(false)
}, []) }, [])
@@ -133,7 +140,7 @@ export const EditUpload: React.FC<EditUploadProps> = ({
((boundsRect.left - containerRect.left + boundsRect.width / 2) / containerRect.width) * 100 ((boundsRect.left - containerRect.left + boundsRect.width / 2) / containerRect.width) * 100
const yCenter = const yCenter =
((boundsRect.top - containerRect.top + boundsRect.height / 2) / containerRect.height) * 100 ((boundsRect.top - containerRect.top + boundsRect.height / 2) / containerRect.height) * 100
setPointPosition({ x: xCenter, y: yCenter }) setFocalPosition({ x: xCenter, y: yCenter })
} }
const fileSrcToUse = imageCacheTag ? `${fileSrc}?${imageCacheTag}` : fileSrc const fileSrcToUse = imageCacheTag ? `${fileSrc}?${imageCacheTag}` : fileSrc
@@ -209,7 +216,7 @@ export const EditUpload: React.FC<EditUploadProps> = ({
checkBounds={showCrop ? checkBounds : false} checkBounds={showCrop ? checkBounds : false}
className={`${baseClass}__focalPoint`} className={`${baseClass}__focalPoint`}
containerRef={focalWrapRef} containerRef={focalWrapRef}
initialPosition={pointPosition} initialPosition={focalPosition}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
setCheckBounds={showCrop ? setCheckBounds : false} setCheckBounds={showCrop ? setCheckBounds : false}
> >
@@ -280,13 +287,13 @@ export const EditUpload: React.FC<EditUploadProps> = ({
<div className={`${baseClass}__inputsWrap`}> <div className={`${baseClass}__inputsWrap`}>
<Input <Input
name="X %" name="X %"
onChange={(value) => fineTuneFocalPoint({ coordinate: 'x', value })} onChange={(value) => fineTuneFocalPosition({ coordinate: 'x', value })}
value={pointPosition.x.toFixed(0)} value={focalPosition.x.toFixed(0)}
/> />
<Input <Input
name="Y %" name="Y %"
onChange={(value) => fineTuneFocalPoint({ coordinate: 'y', value })} onChange={(value) => fineTuneFocalPosition({ coordinate: 'y', value })}
value={pointPosition.y.toFixed(0)} value={focalPosition.y.toFixed(0)}
/> />
</div> </div>
</div> </div>

View File

@@ -101,7 +101,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
}, [setValue]) }, [setValue])
const onEditsSave = React.useCallback( const onEditsSave = React.useCallback(
({ crop, pointPosition }) => { ({ crop, focalPosition }) => {
setCrop({ setCrop({
x: crop.x || 0, x: crop.x || 0,
y: crop.y || 0, y: crop.y || 0,
@@ -122,10 +122,10 @@ export const Upload: React.FC<UploadProps> = (props) => {
type: 'SET', type: 'SET',
params: { params: {
uploadEdits: uploadEdits:
crop || pointPosition crop || focalPosition
? { ? {
crop: crop || null, crop: crop || null,
focalPoint: pointPosition ? pointPosition : null, focalPoint: focalPosition ? focalPosition : null,
} }
: null, : null,
}, },
@@ -164,10 +164,12 @@ export const Upload: React.FC<UploadProps> = (props) => {
const hasImageSizes = uploadConfig?.imageSizes?.length > 0 const hasImageSizes = uploadConfig?.imageSizes?.length > 0
const hasResizeOptions = Boolean(uploadConfig?.resizeOptions) const hasResizeOptions = Boolean(uploadConfig?.resizeOptions)
// Explicity check if set to true, default is undefined
const focalPointEnabled = uploadConfig?.focalPoint === true
const { crop: showCrop = true, focalPoint = true } = uploadConfig const { crop: showCrop = true, focalPoint = true } = uploadConfig
const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions) const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled)
const lastSubmittedTime = submitted ? new Date().toISOString() : null const lastSubmittedTime = submitted ? new Date().toISOString() : null
@@ -234,14 +236,13 @@ export const Upload: React.FC<UploadProps> = (props) => {
{(value || doc.filename) && ( {(value || doc.filename) && (
<Drawer Header={null} slug={editDrawerSlug}> <Drawer Header={null} slug={editDrawerSlug}>
<EditUpload <EditUpload
doc={doc || undefined}
fileName={value?.name || doc?.filename} fileName={value?.name || doc?.filename}
fileSrc={fileSrc || doc?.url} fileSrc={fileSrc || doc?.url}
imageCacheTag={lastSubmittedTime} imageCacheTag={lastSubmittedTime}
initialCrop={formQueryParams?.uploadEdits?.crop ?? {}} initialCrop={formQueryParams?.uploadEdits?.crop ?? {}}
initialFocalPoint={{ initialFocalPoint={{
x: formQueryParams?.uploadEdits?.focalPoint.x || 0, x: formQueryParams?.uploadEdits?.focalPoint.x || doc.focalX || 50,
y: formQueryParams?.uploadEdits?.focalPoint.y || 0, y: formQueryParams?.uploadEdits?.focalPoint.y || doc.focalY || 50,
}} }}
onSave={onEditsSave} onSave={onEditsSave}
showCrop={showCrop} showCrop={showCrop}

View File

@@ -12,6 +12,7 @@ import { Uploads2 } from './collections/Upload2/index.js'
import { import {
audioSlug, audioSlug,
enlargeSlug, enlargeSlug,
focalNoSizesSlug,
mediaSlug, mediaSlug,
reduceSlug, reduceSlug,
relationSlug, relationSlug,
@@ -183,6 +184,16 @@ export default buildConfigWithDefaults({
], ],
}, },
}, },
{
slug: focalNoSizesSlug,
fields: [],
upload: {
crop: false,
focalPoint: true,
mimeTypes: ['image/png', 'image/jpg', 'image/jpeg'],
staticDir: './focal-no-sizes',
},
},
{ {
slug: mediaSlug, slug: mediaSlug,
fields: [], fields: [],

View File

@@ -14,6 +14,8 @@ import configPromise from './config.js'
import { createStreamableFile } from './createStreamableFile.js' import { createStreamableFile } from './createStreamableFile.js'
import { import {
enlargeSlug, enlargeSlug,
focalNoSizesSlug,
focalOnlySlug,
mediaSlug, mediaSlug,
reduceSlug, reduceSlug,
relationSlug, relationSlug,
@@ -73,6 +75,8 @@ describe('Collections - Uploads', () => {
// Check api response // Check api response
expect(doc.mimeType).toEqual('image/png') expect(doc.mimeType).toEqual('image/png')
expect(doc.focalX).toEqual(50)
expect(doc.focalY).toEqual(50)
expect(sizes.maintainedAspectRatio.url).toContain('/api/media/file/image') expect(sizes.maintainedAspectRatio.url).toContain('/api/media/file/image')
expect(sizes.maintainedAspectRatio.url).toContain('.png') expect(sizes.maintainedAspectRatio.url).toContain('.png')
expect(sizes.maintainedAspectRatio.width).toEqual(1024) expect(sizes.maintainedAspectRatio.width).toEqual(1024)
@@ -286,7 +290,6 @@ describe('Collections - Uploads', () => {
expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false) expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false)
}) })
}) })
describe('delete', () => { describe('delete', () => {
it('should remove related files when deleting by ID', async () => { it('should remove related files when deleting by ID', async () => {
const formData = new FormData() const formData = new FormData()
@@ -527,6 +530,88 @@ describe('Collections - Uploads', () => {
}) })
}) })
describe('focal point', () => {
let file
beforeAll(async () => {
// Create image
const filePath = path.resolve(dirname, './image.png')
file = await getFileByPath(filePath)
file.name = 'focal.png'
})
it('should be able to set focal point through local API', async () => {
const doc = await payload.create({
collection: focalOnlySlug,
data: {
focalX: 5,
focalY: 5,
},
file,
})
expect(doc.focalX).toEqual(5)
expect(doc.focalY).toEqual(5)
const updatedFocal = await payload.update({
collection: focalOnlySlug,
id: doc.id,
data: {
focalX: 10,
focalY: 10,
},
})
expect(updatedFocal.focalX).toEqual(10)
expect(updatedFocal.focalY).toEqual(10)
const updateWithoutFocal = await payload.update({
collection: focalOnlySlug,
id: doc.id,
data: {},
})
// Expect focal point to be the same
expect(updateWithoutFocal.focalX).toEqual(10)
expect(updateWithoutFocal.focalY).toEqual(10)
})
it('should default focal point to 50, 50', async () => {
const doc = await payload.create({
collection: focalOnlySlug,
data: {
// No focal point
},
file,
})
expect(doc.focalX).toEqual(50)
expect(doc.focalY).toEqual(50)
const updateWithoutFocal = await payload.update({
collection: focalOnlySlug,
id: doc.id,
data: {},
})
expect(updateWithoutFocal.focalX).toEqual(50)
expect(updateWithoutFocal.focalY).toEqual(50)
})
it('should set focal point even if no sizes defined', async () => {
const doc = await payload.create({
collection: focalNoSizesSlug, // config without sizes
data: {
// No focal point
},
file,
})
expect(doc.focalX).toEqual(50)
expect(doc.focalY).toEqual(50)
})
})
describe('Image Manipulation', () => { describe('Image Manipulation', () => {
it('should enlarge images if resize options `withoutEnlargement` is set to false', async () => { it('should enlarge images if resize options `withoutEnlargement` is set to false', async () => {
const small = await getFileByPath(path.resolve(dirname, './small.png')) const small = await getFileByPath(path.resolve(dirname, './small.png'))

View File

@@ -15,6 +15,7 @@ export interface Config {
'object-fit': ObjectFit; 'object-fit': ObjectFit;
'crop-only': CropOnly; 'crop-only': CropOnly;
'focal-only': FocalOnly; 'focal-only': FocalOnly;
'focal-no-sizes': FocalNoSize;
media: Media; media: Media;
enlarge: Enlarge; enlarge: Enlarge;
reduce: Reduce; reduce: Reduce;
@@ -64,6 +65,8 @@ export interface Media {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
maintainedAspectRatio?: { maintainedAspectRatio?: {
url?: string | null; url?: string | null;
@@ -204,6 +207,8 @@ export interface Version {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -230,6 +235,8 @@ export interface GifResize {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
small?: { small?: {
url?: string | null; url?: string | null;
@@ -264,6 +271,8 @@ export interface NoImageSize {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -280,6 +289,8 @@ export interface ObjectFit {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
fitContain?: { fitContain?: {
url?: string | null; url?: string | null;
@@ -330,6 +341,8 @@ export interface CropOnly {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
focalTest?: { focalTest?: {
url?: string | null; url?: string | null;
@@ -372,6 +385,8 @@ export interface FocalOnly {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
focalTest?: { focalTest?: {
url?: string | null; url?: string | null;
@@ -399,6 +414,24 @@ export interface FocalOnly {
}; };
}; };
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "focal-no-sizes".
*/
export interface FocalNoSize {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "enlarge". * via the `definition` "enlarge".
@@ -414,6 +447,8 @@ export interface Enlarge {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
accidentalSameSize?: { accidentalSameSize?: {
url?: string | null; url?: string | null;
@@ -472,6 +507,8 @@ export interface Reduce {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
accidentalSameSize?: { accidentalSameSize?: {
url?: string | null; url?: string | null;
@@ -522,6 +559,8 @@ export interface MediaTrim {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
trimNumber?: { trimNumber?: {
url?: string | null; url?: string | null;
@@ -564,6 +603,8 @@ export interface UnstoredMedia {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -580,6 +621,8 @@ export interface ExternallyServedMedia {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -612,6 +655,8 @@ export interface Uploads1 {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -629,6 +674,8 @@ export interface Uploads2 {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -645,6 +692,8 @@ export interface AdminThumbnailFunction {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -661,6 +710,8 @@ export interface AdminThumbnailSize {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: { sizes?: {
small?: { small?: {
url?: string | null; url?: string | null;
@@ -695,6 +746,8 @@ export interface OptionalFile {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -711,6 +764,8 @@ export interface RequiredFile {
filesize?: number | null; filesize?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
focalX?: number | null;
focalY?: number | null;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema

View File

@@ -1,19 +1,12 @@
export const usersSlug = 'users' export const usersSlug = 'users'
export const mediaSlug = 'media' export const mediaSlug = 'media'
export const relationSlug = 'relation' export const relationSlug = 'relation'
export const audioSlug = 'audio' export const audioSlug = 'audio'
export const enlargeSlug = 'enlarge' export const enlargeSlug = 'enlarge'
export const focalNoSizesSlug = 'focal-no-sizes'
export const focalOnlySlug = 'focal-only'
export const reduceSlug = 'reduce' export const reduceSlug = 'reduce'
export const adminThumbnailFunctionSlug = 'admin-thumbnail-function' export const adminThumbnailFunctionSlug = 'admin-thumbnail-function'
export const adminThumbnailSizeSlug = 'admin-thumbnail-size' export const adminThumbnailSizeSlug = 'admin-thumbnail-size'
export const unstoredMediaSlug = 'unstored-media' export const unstoredMediaSlug = 'unstored-media'
export const versionSlug = 'versions' export const versionSlug = 'versions'