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:
@@ -152,7 +152,7 @@ type FetchAPIFileUpload = (args: {
|
||||
request: Request
|
||||
}) => Promise<FetchAPIFileUploadResponse>
|
||||
export const fetchAPIFileUpload: FetchAPIFileUpload = async ({ options, request }) => {
|
||||
const uploadOptions = { ...DEFAULT_OPTIONS, ...options }
|
||||
const uploadOptions: FetchAPIFileUploadOptions = { ...DEFAULT_OPTIONS, ...options }
|
||||
if (!isEligibleRequest(request)) {
|
||||
debugLog(uploadOptions, 'Request is not eligible for file upload!')
|
||||
return {
|
||||
|
||||
@@ -121,6 +121,7 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
collection,
|
||||
config,
|
||||
data,
|
||||
operation: 'create',
|
||||
overwriteExistingFiles,
|
||||
req,
|
||||
throwOnMissingFile:
|
||||
|
||||
@@ -156,6 +156,7 @@ export const updateOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
collection,
|
||||
config,
|
||||
data: bulkUpdateData,
|
||||
operation: 'update',
|
||||
overwriteExistingFiles,
|
||||
req,
|
||||
throwOnMissingFile: false,
|
||||
|
||||
@@ -147,6 +147,7 @@ export const updateByIDOperation = async <TSlug extends keyof GeneratedTypes['co
|
||||
collection,
|
||||
config,
|
||||
data,
|
||||
operation: 'update',
|
||||
overwriteExistingFiles,
|
||||
req,
|
||||
throwOnMissingFile: false,
|
||||
|
||||
@@ -12,7 +12,7 @@ export { traverseFields as beforeValidateTraverseFields } from '../fields/hooks/
|
||||
|
||||
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 {
|
||||
|
||||
@@ -7,19 +7,6 @@ import type { GeneratedTypes } from '../index.js'
|
||||
import type { validOperators } from './constants.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 = {
|
||||
context: RequestContext
|
||||
/** The locale that should be used for a field when it is not translated to the requested locale */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export const percentToPixel = (value, 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 {
|
||||
const { height, width, x, y } = cropData
|
||||
|
||||
|
||||
@@ -9,22 +9,24 @@ import sanitize from 'sanitize-filename'
|
||||
import type { Collection } from '../collections/config/types.js'
|
||||
import type { SanitizedConfig } from '../config/types.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 canResizeImage from './canResizeImage.js'
|
||||
import cropImage from './cropImage.js'
|
||||
import { canResizeImage } from './canResizeImage.js'
|
||||
import { cropImage } from './cropImage.js'
|
||||
import { getExternalFile } from './getExternalFile.js'
|
||||
import { getFileByPath } from './getFileByPath.js'
|
||||
import { getImageSize } from './getImageSize.js'
|
||||
import getSafeFileName from './getSafeFilename.js'
|
||||
import resizeAndTransformImageSizes from './imageResizer.js'
|
||||
import isImage from './isImage.js'
|
||||
import { getSafeFileName } from './getSafeFilename.js'
|
||||
import { resizeAndTransformImageSizes } from './imageResizer.js'
|
||||
import { isImage } from './isImage.js'
|
||||
|
||||
type Args<T> = {
|
||||
collection: Collection
|
||||
config: SanitizedConfig
|
||||
data: T
|
||||
operation: 'create' | 'update'
|
||||
originalDoc?: T
|
||||
overwriteExistingFiles?: boolean
|
||||
req: PayloadRequestWithData
|
||||
throwOnMissingFile?: boolean
|
||||
@@ -38,6 +40,8 @@ type Result<T> = Promise<{
|
||||
export const generateFileData = async <T>({
|
||||
collection: { config: collectionConfig },
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
overwriteExistingFiles,
|
||||
req,
|
||||
throwOnMissingFile,
|
||||
@@ -53,10 +57,22 @@ export const generateFileData = async <T>({
|
||||
|
||||
let file = req.file
|
||||
|
||||
const uploadEdits = req.query['uploadEdits'] || {}
|
||||
const uploadEdits = parseUploadEditsFromReqOrIncomingData({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
req,
|
||||
})
|
||||
|
||||
const { disableLocalStorage, formatOptions, imageSizes, resizeOptions, staticDir, trimOptions } =
|
||||
collectionConfig.upload
|
||||
const {
|
||||
disableLocalStorage,
|
||||
focalPoint: focalPointEnabled = true,
|
||||
formatOptions,
|
||||
imageSizes,
|
||||
resizeOptions,
|
||||
staticDir,
|
||||
trimOptions,
|
||||
} = collectionConfig.upload
|
||||
|
||||
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 = {}
|
||||
const { sizeData, sizesToSave } = await resizeAndTransformImageSizes({
|
||||
const { focalPoint, sizeData, sizesToSave } = await resizeAndTransformImageSizes({
|
||||
config: collectionConfig,
|
||||
dimensions: !cropData
|
||||
? dimensions
|
||||
@@ -245,13 +261,16 @@ export const generateFileData = async <T>({
|
||||
savedFilename: fsSafeName || file.name,
|
||||
sharp,
|
||||
staticPath,
|
||||
uploadEdits,
|
||||
})
|
||||
|
||||
fileData.sizes = sizeData
|
||||
fileData.focalX = focalPoint?.x
|
||||
fileData.focalY = focalPoint?.y
|
||||
filesToSave.push(...sizesToSave)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
req.payload.logger.error(err)
|
||||
throw new FileUploadError(req.t)
|
||||
}
|
||||
|
||||
@@ -265,3 +284,50 @@ export const generateFileData = async <T>({
|
||||
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
|
||||
}
|
||||
|
||||
@@ -149,6 +149,25 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
|
||||
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) {
|
||||
mimeType.validate = mimeTypeValidator(uploadOptions.mimeTypes)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type Args = {
|
||||
staticPath: string
|
||||
}
|
||||
|
||||
async function getSafeFileName({
|
||||
export async function getSafeFileName({
|
||||
collectionSlug,
|
||||
desiredFilename,
|
||||
req,
|
||||
@@ -51,5 +51,3 @@ async function getSafeFileName({
|
||||
}
|
||||
return modifiedFilename
|
||||
}
|
||||
|
||||
export default getSafeFileName
|
||||
|
||||
@@ -8,8 +8,15 @@ import sanitize from 'sanitize-filename'
|
||||
|
||||
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
|
||||
import type { SharpDependency } from '../config/types.js'
|
||||
import type { PayloadRequestWithData, UploadEdits } from '../types/index.js'
|
||||
import type { FileSize, FileSizes, FileToSave, ImageSize, ProbedImageSize } from './types.js'
|
||||
import type { PayloadRequestWithData } from '../types/index.js'
|
||||
import type {
|
||||
FileSize,
|
||||
FileSizes,
|
||||
FileToSave,
|
||||
ImageSize,
|
||||
ProbedImageSize,
|
||||
UploadEdits,
|
||||
} from './types.js'
|
||||
|
||||
import { isNumber } from '../utilities/isNumber.js'
|
||||
import fileExists from './fileExists.js'
|
||||
@@ -19,18 +26,16 @@ type ResizeArgs = {
|
||||
dimensions: ProbedImageSize
|
||||
file: PayloadRequestWithData['file']
|
||||
mimeType: string
|
||||
req: PayloadRequestWithData & {
|
||||
query?: {
|
||||
uploadEdits?: UploadEdits
|
||||
}
|
||||
}
|
||||
req: PayloadRequestWithData
|
||||
savedFilename: string
|
||||
sharp: SharpDependency
|
||||
sharp?: SharpDependency
|
||||
staticPath: string
|
||||
uploadEdits?: UploadEdits
|
||||
}
|
||||
|
||||
/** Result from resizing and transforming the requested image sizes */
|
||||
type ImageSizesResult = {
|
||||
focalPoint?: UploadEdits['focalPoint']
|
||||
sizeData: FileSizes
|
||||
sizesToSave: FileToSave[]
|
||||
}
|
||||
@@ -71,6 +76,16 @@ const createImageName = (
|
||||
extension: string,
|
||||
) => `${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
|
||||
* provided parameters. If the name is not provided, an empty result object
|
||||
@@ -85,26 +100,28 @@ const createImageName = (
|
||||
* @param sizesToSave - the sizes to save
|
||||
* @returns the result object
|
||||
*/
|
||||
const createResult = (
|
||||
name: string,
|
||||
filename: FileSize['filename'] = null,
|
||||
width: FileSize['width'] = null,
|
||||
height: FileSize['height'] = null,
|
||||
filesize: FileSize['filesize'] = null,
|
||||
mimeType: FileSize['mimeType'] = null,
|
||||
sizesToSave: FileToSave[] = [],
|
||||
): ImageSizesResult => ({
|
||||
sizeData: {
|
||||
[name]: {
|
||||
filename,
|
||||
filesize,
|
||||
height,
|
||||
mimeType,
|
||||
width,
|
||||
const createResult = ({
|
||||
name,
|
||||
filename = null,
|
||||
filesize = null,
|
||||
height = null,
|
||||
mimeType = null,
|
||||
sizesToSave = [],
|
||||
width = null,
|
||||
}: CreateResultArgs): ImageSizesResult => {
|
||||
return {
|
||||
sizeData: {
|
||||
[name]: {
|
||||
filename,
|
||||
filesize,
|
||||
height,
|
||||
mimeType,
|
||||
width,
|
||||
},
|
||||
},
|
||||
},
|
||||
sizesToSave,
|
||||
})
|
||||
sizesToSave,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns the result of the resize operation(s)
|
||||
*/
|
||||
export default async function resizeAndTransformImageSizes({
|
||||
export async function resizeAndTransformImageSizes({
|
||||
config,
|
||||
dimensions,
|
||||
file,
|
||||
@@ -217,10 +234,27 @@ export default async function resizeAndTransformImageSizes({
|
||||
savedFilename,
|
||||
sharp,
|
||||
staticPath,
|
||||
uploadEdits,
|
||||
}: ResizeArgs): Promise<ImageSizesResult> {
|
||||
const { imageSizes } = config.upload
|
||||
// Noting to resize here so return as early as possible
|
||||
if (!imageSizes) return { sizeData: {}, sizesToSave: [] }
|
||||
const { focalPoint: focalPointEnabled = true, imageSizes } = config.upload
|
||||
|
||||
// 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
|
||||
|
||||
@@ -232,16 +266,13 @@ export default async function resizeAndTransformImageSizes({
|
||||
// skipped COMPLETELY and thus will not be included in the resulting images.
|
||||
// All further format/trim options will thus be skipped as well.
|
||||
if (preventResize(imageResizeConfig, dimensions)) {
|
||||
return createResult(imageResizeConfig.name)
|
||||
return createResult({ name: imageResizeConfig.name })
|
||||
}
|
||||
|
||||
const imageToResize = sharpBase.clone()
|
||||
let resized = imageToResize
|
||||
|
||||
if (
|
||||
req.query?.uploadEdits?.focalPoint &&
|
||||
applyPayloadAdjustments(imageResizeConfig, dimensions)
|
||||
) {
|
||||
if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) {
|
||||
const { height: resizeHeight, width: resizeWidth } = imageResizeConfig
|
||||
const resizeAspectRatio = resizeWidth / resizeHeight
|
||||
const originalAspectRatio = dimensions.width / dimensions.height
|
||||
@@ -254,27 +285,17 @@ export default async function resizeAndTransformImageSizes({
|
||||
})
|
||||
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 maxOffsetX = scaledImageInfo.width - safeResizeWidth
|
||||
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 safeResizeHeight = resizeHeight ?? scaledImageInfo.height
|
||||
const maxOffsetY = scaledImageInfo.height - safeResizeHeight
|
||||
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)
|
||||
|
||||
@@ -306,7 +327,9 @@ export default async function resizeAndTransformImageSizes({
|
||||
|
||||
const sanitizedImage = getSanitizedImageData(savedFilename)
|
||||
|
||||
req.payloadUploadSizes[imageResizeConfig.name] = bufferData
|
||||
if (req.payloadUploadSizes) {
|
||||
req.payloadUploadSizes[imageResizeConfig.name] = bufferData
|
||||
}
|
||||
|
||||
const mimeInfo = await fromBuffer(bufferData)
|
||||
|
||||
@@ -327,15 +350,15 @@ export default async function resizeAndTransformImageSizes({
|
||||
}
|
||||
|
||||
const { height, size, width } = bufferInfo
|
||||
return createResult(
|
||||
imageResizeConfig.name,
|
||||
imageNameWithDimensions,
|
||||
width,
|
||||
return createResult({
|
||||
name: imageResizeConfig.name,
|
||||
filename: imageNameWithDimensions,
|
||||
filesize: size,
|
||||
height,
|
||||
size,
|
||||
mimeInfo?.mime || mimeType,
|
||||
[{ buffer: bufferData, path: imagePath }],
|
||||
)
|
||||
mimeType: mimeInfo?.mime || mimeType,
|
||||
sizesToSave: [{ buffer: bufferData, path: imagePath }],
|
||||
width,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -345,6 +368,6 @@ export default async function resizeAndTransformImageSizes({
|
||||
acc.sizesToSave.push(...result.sizesToSave)
|
||||
return acc
|
||||
},
|
||||
{ sizeData: {}, sizesToSave: [] },
|
||||
{ ...defaultResult },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function isImage(mimeType: string): boolean {
|
||||
export function isImage(mimeType: string): boolean {
|
||||
return (
|
||||
['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif'].indexOf(
|
||||
mimeType,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type FileSizes = {
|
||||
export type FileData = {
|
||||
filename: string
|
||||
filesize: number
|
||||
focalX?: number
|
||||
focalY?: number
|
||||
height: number
|
||||
mimeType: string
|
||||
sizes: FileSizes
|
||||
@@ -117,3 +119,16 @@ export type FileToSave = {
|
||||
buffer: Buffer
|
||||
path: string
|
||||
}
|
||||
|
||||
export type UploadEdits = {
|
||||
crop?: {
|
||||
height?: number
|
||||
width?: number
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
focalPoint?: {
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,12 @@ type FocalPosition = {
|
||||
}
|
||||
|
||||
export type EditUploadProps = {
|
||||
doc?: Data
|
||||
fileName: string
|
||||
fileSrc: string
|
||||
imageCacheTag?: string
|
||||
initialCrop?: CropType
|
||||
initialFocalPoint?: FocalPosition
|
||||
onSave?: ({ crop, pointPosition }: { crop: CropType; pointPosition: FocalPosition }) => void
|
||||
onSave?: ({ crop, focalPosition }: { crop: CropType; focalPosition: FocalPosition }) => void
|
||||
showCrop?: boolean
|
||||
showFocalPoint?: boolean
|
||||
}
|
||||
@@ -51,11 +50,6 @@ const defaultCrop: CropType = {
|
||||
y: 0,
|
||||
}
|
||||
|
||||
const defaultPointPosition: FocalPosition = {
|
||||
x: 50,
|
||||
y: 50,
|
||||
}
|
||||
|
||||
export const EditUpload: React.FC<EditUploadProps> = ({
|
||||
fileName,
|
||||
fileSrc,
|
||||
@@ -76,8 +70,15 @@ export const EditUpload: React.FC<EditUploadProps> = ({
|
||||
...initialCrop,
|
||||
}))
|
||||
|
||||
const [pointPosition, setPointPosition] = useState<FocalPosition>(() => ({
|
||||
...defaultPointPosition,
|
||||
const defaultFocalPosition: FocalPosition = {
|
||||
x: 50,
|
||||
y: 50,
|
||||
}
|
||||
|
||||
console.log({ initialFocalPoint })
|
||||
|
||||
const [focalPosition, setFocalPosition] = useState<FocalPosition>(() => ({
|
||||
...defaultFocalPosition,
|
||||
...initialFocalPoint,
|
||||
}))
|
||||
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)
|
||||
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')
|
||||
onSave({
|
||||
crop,
|
||||
pointPosition,
|
||||
focalPosition,
|
||||
})
|
||||
closeModal(editDrawerSlug)
|
||||
}
|
||||
|
||||
const onDragEnd = React.useCallback(({ x, y }) => {
|
||||
setPointPosition({ x, y })
|
||||
setFocalPosition({ x, y })
|
||||
setCheckBounds(false)
|
||||
}, [])
|
||||
|
||||
@@ -133,7 +140,7 @@ export const EditUpload: React.FC<EditUploadProps> = ({
|
||||
((boundsRect.left - containerRect.left + boundsRect.width / 2) / containerRect.width) * 100
|
||||
const yCenter =
|
||||
((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
|
||||
@@ -209,7 +216,7 @@ export const EditUpload: React.FC<EditUploadProps> = ({
|
||||
checkBounds={showCrop ? checkBounds : false}
|
||||
className={`${baseClass}__focalPoint`}
|
||||
containerRef={focalWrapRef}
|
||||
initialPosition={pointPosition}
|
||||
initialPosition={focalPosition}
|
||||
onDragEnd={onDragEnd}
|
||||
setCheckBounds={showCrop ? setCheckBounds : false}
|
||||
>
|
||||
@@ -280,13 +287,13 @@ export const EditUpload: React.FC<EditUploadProps> = ({
|
||||
<div className={`${baseClass}__inputsWrap`}>
|
||||
<Input
|
||||
name="X %"
|
||||
onChange={(value) => fineTuneFocalPoint({ coordinate: 'x', value })}
|
||||
value={pointPosition.x.toFixed(0)}
|
||||
onChange={(value) => fineTuneFocalPosition({ coordinate: 'x', value })}
|
||||
value={focalPosition.x.toFixed(0)}
|
||||
/>
|
||||
<Input
|
||||
name="Y %"
|
||||
onChange={(value) => fineTuneFocalPoint({ coordinate: 'y', value })}
|
||||
value={pointPosition.y.toFixed(0)}
|
||||
onChange={(value) => fineTuneFocalPosition({ coordinate: 'y', value })}
|
||||
value={focalPosition.y.toFixed(0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
|
||||
}, [setValue])
|
||||
|
||||
const onEditsSave = React.useCallback(
|
||||
({ crop, pointPosition }) => {
|
||||
({ crop, focalPosition }) => {
|
||||
setCrop({
|
||||
x: crop.x || 0,
|
||||
y: crop.y || 0,
|
||||
@@ -122,10 +122,10 @@ export const Upload: React.FC<UploadProps> = (props) => {
|
||||
type: 'SET',
|
||||
params: {
|
||||
uploadEdits:
|
||||
crop || pointPosition
|
||||
crop || focalPosition
|
||||
? {
|
||||
crop: crop || null,
|
||||
focalPoint: pointPosition ? pointPosition : null,
|
||||
focalPoint: focalPosition ? focalPosition : null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
@@ -164,10 +164,12 @@ export const Upload: React.FC<UploadProps> = (props) => {
|
||||
|
||||
const hasImageSizes = uploadConfig?.imageSizes?.length > 0
|
||||
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 showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions)
|
||||
const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled)
|
||||
|
||||
const lastSubmittedTime = submitted ? new Date().toISOString() : null
|
||||
|
||||
@@ -234,14 +236,13 @@ export const Upload: React.FC<UploadProps> = (props) => {
|
||||
{(value || doc.filename) && (
|
||||
<Drawer Header={null} slug={editDrawerSlug}>
|
||||
<EditUpload
|
||||
doc={doc || undefined}
|
||||
fileName={value?.name || doc?.filename}
|
||||
fileSrc={fileSrc || doc?.url}
|
||||
imageCacheTag={lastSubmittedTime}
|
||||
initialCrop={formQueryParams?.uploadEdits?.crop ?? {}}
|
||||
initialFocalPoint={{
|
||||
x: formQueryParams?.uploadEdits?.focalPoint.x || 0,
|
||||
y: formQueryParams?.uploadEdits?.focalPoint.y || 0,
|
||||
x: formQueryParams?.uploadEdits?.focalPoint.x || doc.focalX || 50,
|
||||
y: formQueryParams?.uploadEdits?.focalPoint.y || doc.focalY || 50,
|
||||
}}
|
||||
onSave={onEditsSave}
|
||||
showCrop={showCrop}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Uploads2 } from './collections/Upload2/index.js'
|
||||
import {
|
||||
audioSlug,
|
||||
enlargeSlug,
|
||||
focalNoSizesSlug,
|
||||
mediaSlug,
|
||||
reduceSlug,
|
||||
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,
|
||||
fields: [],
|
||||
|
||||
@@ -14,6 +14,8 @@ import configPromise from './config.js'
|
||||
import { createStreamableFile } from './createStreamableFile.js'
|
||||
import {
|
||||
enlargeSlug,
|
||||
focalNoSizesSlug,
|
||||
focalOnlySlug,
|
||||
mediaSlug,
|
||||
reduceSlug,
|
||||
relationSlug,
|
||||
@@ -73,6 +75,8 @@ describe('Collections - Uploads', () => {
|
||||
|
||||
// Check api response
|
||||
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('.png')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('should remove related files when deleting by ID', async () => {
|
||||
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', () => {
|
||||
it('should enlarge images if resize options `withoutEnlargement` is set to false', async () => {
|
||||
const small = await getFileByPath(path.resolve(dirname, './small.png'))
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Config {
|
||||
'object-fit': ObjectFit;
|
||||
'crop-only': CropOnly;
|
||||
'focal-only': FocalOnly;
|
||||
'focal-no-sizes': FocalNoSize;
|
||||
media: Media;
|
||||
enlarge: Enlarge;
|
||||
reduce: Reduce;
|
||||
@@ -64,6 +65,8 @@ export interface Media {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
maintainedAspectRatio?: {
|
||||
url?: string | null;
|
||||
@@ -204,6 +207,8 @@ export interface Version {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -230,6 +235,8 @@ export interface GifResize {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
small?: {
|
||||
url?: string | null;
|
||||
@@ -264,6 +271,8 @@ export interface NoImageSize {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -280,6 +289,8 @@ export interface ObjectFit {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
fitContain?: {
|
||||
url?: string | null;
|
||||
@@ -330,6 +341,8 @@ export interface CropOnly {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
focalTest?: {
|
||||
url?: string | null;
|
||||
@@ -372,6 +385,8 @@ export interface FocalOnly {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
focalTest?: {
|
||||
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
|
||||
* via the `definition` "enlarge".
|
||||
@@ -414,6 +447,8 @@ export interface Enlarge {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
accidentalSameSize?: {
|
||||
url?: string | null;
|
||||
@@ -472,6 +507,8 @@ export interface Reduce {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
accidentalSameSize?: {
|
||||
url?: string | null;
|
||||
@@ -522,6 +559,8 @@ export interface MediaTrim {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
trimNumber?: {
|
||||
url?: string | null;
|
||||
@@ -564,6 +603,8 @@ export interface UnstoredMedia {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -580,6 +621,8 @@ export interface ExternallyServedMedia {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -612,6 +655,8 @@ export interface Uploads1 {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -629,6 +674,8 @@ export interface Uploads2 {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -645,6 +692,8 @@ export interface AdminThumbnailFunction {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -661,6 +710,8 @@ export interface AdminThumbnailSize {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
small?: {
|
||||
url?: string | null;
|
||||
@@ -695,6 +746,8 @@ export interface OptionalFile {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -711,6 +764,8 @@ export interface RequiredFile {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
export const usersSlug = 'users'
|
||||
|
||||
export const mediaSlug = 'media'
|
||||
|
||||
export const relationSlug = 'relation'
|
||||
|
||||
export const audioSlug = 'audio'
|
||||
|
||||
export const enlargeSlug = 'enlarge'
|
||||
|
||||
export const focalNoSizesSlug = 'focal-no-sizes'
|
||||
export const focalOnlySlug = 'focal-only'
|
||||
export const reduceSlug = 'reduce'
|
||||
|
||||
export const adminThumbnailFunctionSlug = 'admin-thumbnail-function'
|
||||
|
||||
export const adminThumbnailSizeSlug = 'admin-thumbnail-size'
|
||||
|
||||
export const unstoredMediaSlug = 'unstored-media'
|
||||
|
||||
export const versionSlug = 'versions'
|
||||
|
||||
Reference in New Issue
Block a user