fix(ui): uploading from drawer & focal point positioning (#7117)

Fixes #7101
Fixes #7006

Drawers were sending duplicate query params. This new approach modeled after the fix in V2, ensures that each drawer has its own action url created per document and the query params will be created when that is generated.

Also fixes the following:
- incorrect focal point cropping
- generated filenames for animated image names used incorrect heights
This commit is contained in:
Patrik
2024-07-18 13:43:53 -04:00
committed by GitHub
parent 448186f374
commit 00771b1f2a
21 changed files with 463 additions and 395 deletions

View File

@@ -180,7 +180,6 @@ import {
useFormInitializing,
useFormModified,
useFormProcessing,
useFormQueryParams,
useFormSubmitted,
useHotkey,
useIntersect,
@@ -221,7 +220,6 @@ import {
EntityVisibilityProvider,
FieldComponentsProvider,
FieldPropsProvider,
FormQueryParamsProvider,
ListInfoProvider,
ListQueryProvider,
LocaleProvider,

View File

@@ -1,6 +1,6 @@
import type { AdminViewProps, ServerSideEditViewProps } from 'payload'
import { DocumentInfoProvider, FormQueryParamsProvider, HydrateClientUser } from '@payloadcms/ui'
import { DocumentInfoProvider, HydrateClientUser } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js'
import React from 'react'
@@ -65,7 +65,6 @@ export const Account: React.FC<AdminViewProps> = async ({
return (
<DocumentInfoProvider
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} />}
action={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug}
docPermissions={docPermissions}
@@ -84,31 +83,22 @@ export const Account: React.FC<AdminViewProps> = async ({
permissions={permissions}
/>
<HydrateClientUser permissions={permissions} user={user} />
<FormQueryParamsProvider
initialParams={{
depth: 0,
'fallback-locale': 'null',
locale: locale?.code,
uploadEdits: undefined,
<RenderCustomComponent
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined
}
DefaultComponent={EditView}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
>
<RenderCustomComponent
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined
}
DefaultComponent={EditView}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
</FormQueryParamsProvider>
/>
</DocumentInfoProvider>
)
}

View File

@@ -1,16 +1,6 @@
import type {
AdminViewComponent,
AdminViewProps,
EditViewComponent,
ServerSideEditViewProps,
} from 'payload'
import type { AdminViewComponent, AdminViewProps, EditViewComponent } from 'payload'
import {
DocumentInfoProvider,
EditDepthProvider,
FormQueryParamsProvider,
HydrateClientUser,
} from '@payloadcms/ui'
import { DocumentInfoProvider, EditDepthProvider, HydrateClientUser } from '@payloadcms/ui'
import { RenderCustomComponent, isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
import React from 'react'
@@ -65,7 +55,6 @@ export const Document: React.FC<AdminViewProps> = async ({
let ErrorView: AdminViewComponent
let apiURL: string
let action: string
const { data, formState } = await getDocumentData({
id,
@@ -88,8 +77,6 @@ export const Document: React.FC<AdminViewProps> = async ({
notFound()
}
action = `${serverURL}${apiRoute}/${collectionSlug}${isEditing ? `/${id}` : ''}`
const params = new URLSearchParams()
if (collectionConfig.versions?.drafts) {
params.append('draft', 'true')
@@ -128,8 +115,6 @@ export const Document: React.FC<AdminViewProps> = async ({
notFound()
}
action = `${serverURL}${apiRoute}/globals/${globalSlug}`
const params = new URLSearchParams({
locale: locale?.code,
})
@@ -198,7 +183,6 @@ export const Document: React.FC<AdminViewProps> = async ({
return (
<DocumentInfoProvider
action={action}
apiURL={apiURL}
collectionSlug={collectionConfig?.slug}
disableActions={false}
@@ -225,34 +209,25 @@ export const Document: React.FC<AdminViewProps> = async ({
depth={1}
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}
>
<FormQueryParamsProvider
initialParams={{
depth: 0,
'fallback-locale': 'null',
locale: locale?.code,
uploadEdits: undefined,
}}
>
{ErrorView ? (
<ErrorView initPageResult={initPageResult} searchParams={searchParams} />
) : (
<RenderCustomComponent
CustomComponent={ViewOverride || CustomView}
DefaultComponent={DefaultView}
serverOnlyProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
}}
/>
)}
</FormQueryParamsProvider>
{ErrorView ? (
<ErrorView initPageResult={initPageResult} searchParams={searchParams} />
) : (
<RenderCustomComponent
CustomComponent={ViewOverride || CustomView}
DefaultComponent={DefaultView}
serverOnlyProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
}}
/>
)}
</EditDepthProvider>
</DocumentInfoProvider>
)

View File

@@ -13,7 +13,7 @@ import {
useDocumentEvents,
useDocumentInfo,
useEditDepth,
useFormQueryParams,
useUploadEdits,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import { useRouter, useSearchParams } from 'next/navigation.js'
@@ -58,11 +58,13 @@ export const DefaultEditView: React.FC = () => {
const { refreshCookieAsync, user } = useAuth()
const config = useConfig()
const router = useRouter()
const { dispatchFormQueryParams } = useFormQueryParams()
const { getComponentMap, getFieldMap } = useComponentMap()
const params = useSearchParams()
const depth = useEditDepth()
const params = useSearchParams()
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const locale = params.get('locale')
const {
admin: { user: userSlug },
@@ -72,8 +74,6 @@ export const DefaultEditView: React.FC = () => {
serverURL,
} = config
const locale = params.get('locale')
const collectionConfig =
collectionSlug && collections.find((collection) => collection.slug === collectionSlug)
@@ -130,12 +130,7 @@ export const DefaultEditView: React.FC = () => {
const redirectRoute = `${adminRoute}/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`
router.push(redirectRoute)
} else {
dispatchFormQueryParams({
type: 'SET',
params: {
uploadEdits: null,
},
})
resetUploadEdits()
}
},
[
@@ -151,9 +146,9 @@ export const DefaultEditView: React.FC = () => {
isEditing,
refreshCookieAsync,
adminRoute,
locale,
router,
dispatchFormQueryParams,
locale,
resetUploadEdits,
],
)

View File

@@ -1,12 +1,31 @@
import type { SharpOptions } from 'sharp'
import type { SanitizedConfig } from '../config/types.js'
import type { PayloadRequest } from '../types/index.js'
import type { UploadEdits } from './types.js'
export const percentToPixel = (value, dimension) => {
return Math.floor((parseFloat(value) / 100) * dimension)
}
export async function cropImage({ cropData, dimensions, file, sharp }) {
type CropImageArgs = {
cropData: UploadEdits['crop']
dimensions: { height: number; width: number }
file: PayloadRequest['file']
heightInPixels: number
sharp: SanitizedConfig['sharp']
widthInPixels: number
}
export async function cropImage({
cropData,
dimensions,
file,
heightInPixels,
sharp,
widthInPixels,
}: CropImageArgs) {
try {
const { heightPixels, widthPixels, x, y } = cropData
const { x, y } = cropData
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
@@ -15,10 +34,10 @@ export async function cropImage({ cropData, dimensions, file, sharp }) {
if (fileIsAnimatedType) sharpOptions.animated = true
const formattedCropData = {
height: Number(heightPixels),
height: Number(heightInPixels),
left: percentToPixel(x, dimensions.width),
top: percentToPixel(y, dimensions.height),
width: Number(widthPixels),
width: Number(widthInPixels),
}
const cropped = sharp(file.tempFilePath || file.data, sharpOptions).extract(formattedCropData)

View File

@@ -203,7 +203,14 @@ export const generateFileData = async <T>({
let fileForResize = file
if (cropData && sharp) {
const { data: croppedImage, info } = await cropImage({ cropData, dimensions, file, sharp })
const { data: croppedImage, info } = await cropImage({
cropData,
dimensions,
file,
heightInPixels: uploadEdits.heightInPixels,
sharp,
widthInPixels: uploadEdits.widthInPixels,
})
filesToSave.push({
buffer: croppedImage,

View File

@@ -1,4 +1,4 @@
import type { OutputInfo, Sharp, SharpOptions } from 'sharp'
import type { Sharp, Metadata as SharpMetadata, SharpOptions } from 'sharp'
import { fileTypeFromBuffer } from 'file-type'
import fs from 'fs'
@@ -68,11 +68,20 @@ const getSanitizedImageData = (sourceImage: string): SanitizedImageData => {
* @param extension - the extension to use
* @returns the new image name that is not taken
*/
const createImageName = (
outputImageName: string,
{ height, width }: OutputInfo,
extension: string,
) => `${outputImageName}-${width}x${height}.${extension}`
type CreateImageNameArgs = {
extension: string
height: number
outputImageName: string
width: number
}
const createImageName = ({
extension,
height,
outputImageName,
width,
}: CreateImageNameArgs): string => {
return `${outputImageName}-${width}x${height}.${extension}`
}
type CreateResultArgs = {
filename?: FileSize['filename']
@@ -122,71 +131,61 @@ const createResult = ({
}
/**
* Check if the image needs to be resized according to the requested dimensions
* and the original image size. If the resize options withoutEnlargement or withoutReduction are provided,
* the image will be resized regardless of the requested dimensions, given that the
* width or height to be resized is provided.
* Determine whether or not to resize the image.
* - resize using image config
* - resize using image config with focal adjustments
* - do not resize at all
*
* @param resizeConfig - object containing the requested dimensions and resize options
* @param original - the original image size
* @returns true if resizing is not needed, false otherwise
*/
const preventResize = (
{ height: desiredHeight, width: desiredWidth, withoutEnlargement, withoutReduction }: ImageSize,
original: ProbedImageSize,
): boolean => {
// default is to allow reduction
if (withoutReduction !== undefined) {
return false // needs resize
}
// default is to prevent enlargement
if (withoutEnlargement !== undefined) {
return false // needs resize
}
const isWidthOrHeightNotDefined = !desiredHeight || !desiredWidth
if (isWidthOrHeightNotDefined) {
// If width and height are not defined, it means there is a format conversion
// and the image needs to be "resized" (transformed).
return false // needs resize
}
const hasInsufficientWidth = desiredWidth > original.width
const hasInsufficientHeight = desiredHeight > original.height
if (hasInsufficientWidth && hasInsufficientHeight) {
// doesn't need resize - prevent enlargement. This should only happen if both width and height are insufficient.
// if only one dimension is insufficient and the other is sufficient, resizing needs to happen, as the image
// should be resized to the sufficient dimension.
return true // do not create a new size
}
return false // needs resize
}
/**
* Check if the image should be passed directly to sharp without payload adjusting properties.
* `imageResizeConfig.withoutEnlargement`:
* - undefined [default]: uploading images with smaller width AND height than the image size will return null
* - false: always enlarge images to the image size
* - true: if the image is smaller than the image size, return the original image
*
* @param resizeConfig - object containing the requested dimensions and resize options
* @param original - the original image size
* @returns true if the image should passed directly to sharp
* `imageResizeConfig.withoutReduction`:
* - false [default]: always enlarge images to the image size
* - true: if the image is smaller than the image size, return the original image
*
* @return 'omit' | 'resize' | 'resizeWithFocalPoint'
*/
const applyPayloadAdjustments = (
{ fit, height, width, withoutEnlargement, withoutReduction }: ImageSize,
original: ProbedImageSize,
) => {
if (fit === 'contain' || fit === 'inside') return false
if (!isNumber(height) && !isNumber(width)) return false
const getImageResizeAction = ({
dimensions: originalImage,
hasFocalPoint,
imageResizeConfig,
}: {
dimensions: ProbedImageSize
hasFocalPoint?: boolean
imageResizeConfig: ImageSize
}): 'omit' | 'resize' | 'resizeWithFocalPoint' => {
const {
fit,
height: targetHeight,
width: targetWidth,
withoutEnlargement,
withoutReduction,
} = imageResizeConfig
const targetAspectRatio = width / height
const originalAspectRatio = original.width / original.height
if (originalAspectRatio === targetAspectRatio) return false
// prevent upscaling by default when x and y are both smaller than target image size
if (targetHeight && targetWidth) {
const originalImageIsSmallerXAndY =
originalImage.width < targetWidth && originalImage.height < targetHeight
if (withoutEnlargement === undefined && originalImageIsSmallerXAndY) {
return 'omit' // prevent image size from being enlarged
}
}
const skipEnlargement = withoutEnlargement && (original.height < height || original.width < width)
const skipReduction = withoutReduction && (original.height > height || original.width > width)
if (skipEnlargement || skipReduction) return false
const originalImageIsSmallerXOrY =
originalImage.width < targetWidth || originalImage.height < targetHeight
if (fit === 'contain' || fit === 'inside') return 'resize'
if (!isNumber(targetHeight) && !isNumber(targetWidth)) return 'resize'
return true
const targetAspectRatio = targetWidth / targetHeight
const originalAspectRatio = originalImage.width / originalImage.height
if (originalAspectRatio === targetAspectRatio) return 'resize'
if (withoutEnlargement && originalImageIsSmallerXOrY) return 'resize'
if (withoutReduction && !originalImageIsSmallerXOrY) return 'resize'
return hasFocalPoint ? 'resizeWithFocalPoint' : 'resize'
}
/**
@@ -209,6 +208,19 @@ const sanitizeResizeConfig = (resizeConfig: ImageSize): ImageSize => {
return resizeConfig
}
/**
* Used to extract height from images, animated or not.
*
* @param sharpMetadata - the sharp metadata
* @returns the height of the image
*/
function extractHeightFromImage(sharpMetadata: SharpMetadata): number {
if (sharpMetadata?.pages) {
return sharpMetadata.height / sharpMetadata.pages
}
return sharpMetadata.height
}
/**
* For the provided image sizes, handle the resizing and the transforms
* (format, trim, etc.) of each requested image size and return the result object.
@@ -261,24 +273,28 @@ export async function resizeAndTransformImageSizes({
if (fileIsAnimatedType) sharpOptions.animated = true
const sharpBase: Sharp | undefined = sharp(file.tempFilePath || file.data, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
const originalImageMeta = await sharpBase.metadata()
const resizeImageMeta = {
height: extractHeightFromImage(originalImageMeta),
width: originalImageMeta.width,
}
const results: ImageSizesResult[] = await Promise.all(
imageSizes.map(async (imageResizeConfig): Promise<ImageSizesResult> => {
imageResizeConfig = sanitizeResizeConfig(imageResizeConfig)
// This checks if a resize should happen. If not, the resized image will be
// 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({ name: imageResizeConfig.name })
}
const resizeAction = getImageResizeAction({
dimensions,
hasFocalPoint: Boolean(incomingFocalPoint),
imageResizeConfig,
})
if (resizeAction === 'omit') return createResult({ name: imageResizeConfig.name })
const imageToResize = sharpBase.clone()
let resized = imageToResize
const metadata = await sharpBase.metadata()
if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) {
if (resizeAction === 'resizeWithFocalPoint') {
let { height: resizeHeight, width: resizeWidth } = imageResizeConfig
const originalAspectRatio = dimensions.width / dimensions.height
@@ -293,44 +309,62 @@ export async function resizeAndTransformImageSizes({
resizeHeight = Math.round(resizeWidth / originalAspectRatio)
}
// Scale the image up or down to fit the resize dimensions
const scaledImage = imageToResize.resize({
height: resizeHeight,
width: resizeWidth,
})
if (!resizeHeight) resizeHeight = resizeImageMeta.height
if (!resizeWidth) resizeWidth = resizeImageMeta.width
const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true })
// if requested image is larger than the incoming size, then resize using sharp and then extract with focal point
if (resizeHeight > resizeImageMeta.height || resizeWidth > resizeImageMeta.width) {
const resizeAspectRatio = resizeWidth / resizeHeight
const prioritizeHeight = resizeAspectRatio < originalAspectRatio
resized = imageToResize.resize({
height: prioritizeHeight ? resizeHeight : undefined,
width: prioritizeHeight ? undefined : resizeWidth,
})
const safeResizeWidth = resizeWidth ?? scaledImageInfo.width
const maxOffsetX = scaledImageInfo.width - safeResizeWidth
const leftFocalEdge = Math.round(
scaledImageInfo.width * (incomingFocalPoint.x / 100) - safeResizeWidth / 2,
)
const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX)
const isAnimated = fileIsAnimatedType && metadata.pages
let safeResizeHeight = resizeHeight ?? scaledImageInfo.height
if (isAnimated && resizeHeight === undefined) {
safeResizeHeight = scaledImageInfo.height / metadata.pages
// must read from buffer, resize.metadata will return the original image metadata
const { info } = await resized.toBuffer({ resolveWithObject: true })
resizeImageMeta.height = extractHeightFromImage({
...originalImageMeta,
height: info.height,
})
resizeImageMeta.width = info.width
}
const maxOffsetY = isAnimated
? safeResizeHeight - (resizeHeight ?? safeResizeHeight)
: scaledImageInfo.height - safeResizeHeight
const halfResizeX = resizeWidth / 2
const xFocalCenter = resizeImageMeta.width * (incomingFocalPoint.x / 100)
const calculatedRightPixelBound = xFocalCenter + halfResizeX
let leftBound = xFocalCenter - halfResizeX
const topFocalEdge = Math.round(
scaledImageInfo.height * (incomingFocalPoint.y / 100) - safeResizeHeight / 2,
)
const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY)
// if the right bound is greater than the image width, adjust the left bound
// keeping focus on the right
if (calculatedRightPixelBound > resizeImageMeta.width) {
leftBound = resizeImageMeta.width - resizeWidth
}
// extract the focal area from the scaled image
resized = (fileIsAnimatedType ? imageToResize : scaledImage).extract({
height: safeResizeHeight,
left: safeOffsetX,
top: safeOffsetY,
width: safeResizeWidth,
// if the left bound is less than 0, adjust the left bound to 0
// keeping the focus on the left
if (leftBound < 0) leftBound = 0
const halfResizeY = resizeHeight / 2
const yFocalCenter = resizeImageMeta.height * (incomingFocalPoint.y / 100)
const calculatedBottomPixelBound = yFocalCenter + halfResizeY
let topBound = yFocalCenter - halfResizeY
// if the bottom bound is greater than the image height, adjust the top bound
// keeping the image as far right as possible
if (calculatedBottomPixelBound > resizeImageMeta.height) {
topBound = resizeImageMeta.height - resizeHeight
}
// if the top bound is less than 0, adjust the top bound to 0
// keeping the image focus near the top
if (topBound < 0) topBound = 0
resized = resized.extract({
height: resizeHeight,
left: Math.floor(leftBound),
top: Math.floor(topBound),
width: resizeWidth,
})
} else {
resized = imageToResize.resize(imageResizeConfig)
@@ -359,11 +393,15 @@ export async function resizeAndTransformImageSizes({
const mimeInfo = await fileTypeFromBuffer(bufferData)
const imageNameWithDimensions = createImageName(
sanitizedImage.name,
bufferInfo,
mimeInfo?.ext || sanitizedImage.ext,
)
const imageNameWithDimensions = createImageName({
extension: mimeInfo?.ext || sanitizedImage.ext,
height: extractHeightFromImage({
...originalImageMeta,
height: bufferInfo.height,
}),
outputImageName: sanitizedImage.name,
width: bufferInfo.width,
})
const imagePath = `${staticPath}/${imageNameWithDimensions}`
@@ -380,7 +418,8 @@ export async function resizeAndTransformImageSizes({
name: imageResizeConfig.name,
filename: imageNameWithDimensions,
filesize: size,
height: fileIsAnimatedType && metadata.pages ? height / metadata.pages : height,
height:
fileIsAnimatedType && originalImageMeta.pages ? height / originalImageMeta.pages : height,
mimeType: mimeInfo?.mime || mimeType,
sizesToSave: [{ buffer: bufferData, path: imagePath }],
width,

View File

@@ -189,15 +189,22 @@ export type FileToSave = {
path: string
}
export type UploadEdits = {
crop?: {
height?: number
width?: number
x?: number
y?: number
}
focalPoint?: {
x?: number
y?: number
}
type Crop = {
height: number
unit: '%' | 'px'
width: number
x: number
y: number
}
type FocalPoint = {
x: number
y: number
}
export type UploadEdits = {
crop?: Crop
focalPoint?: FocalPoint
heightInPixels?: number
widthInPixels?: number
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useModal } from '@faceless-ui/modal'
import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -12,7 +11,6 @@ import { XIcon } from '../../icons/X/index.js'
import { useComponentMap } from '../../providers/ComponentMap/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useFormQueryParams } from '../../providers/FormQueryParams/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Gutter } from '../Gutter/index.js'
@@ -40,8 +38,6 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const [docID, setDocID] = useState(existingDocID)
const [isOpen, setIsOpen] = useState(false)
const [collectionConfig] = useRelatedCollections(collectionSlug)
const { formQueryParams } = useFormQueryParams()
const formattedQueryParams = qs.stringify(formQueryParams)
const { componentMap } = useComponentMap()
@@ -50,9 +46,6 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const apiURL = docID
? `${serverURL}${apiRoute}/${collectionSlug}/${docID}${locale?.code ? `?locale=${locale.code}` : ''}`
: null
const action = `${serverURL}${apiRoute}/${collectionSlug}${
isEditing ? `/${docID}` : ''
}?${formattedQueryParams}`
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen))
@@ -104,7 +97,6 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
<DocumentTitle />
</Gutter>
}
action={action}
apiURL={apiURL}
collectionSlug={collectionConfig.slug}
disableActions

View File

@@ -1,5 +1,6 @@
'use client'
import type CropType from 'react-image-crop'
import type { UploadEdits } from 'payload'
import { useModal } from '@faceless-ui/modal'
import React, { forwardRef, useRef, useState } from 'react'
@@ -46,19 +47,17 @@ export type EditUploadProps = {
fileName: string
fileSrc: string
imageCacheTag?: string
initialCrop?: CropType
initialCrop?: UploadEdits['crop']
initialFocalPoint?: FocalPosition
onSave?: ({ crop, focalPosition }: { crop: CropType; focalPosition: FocalPosition }) => void
onSave?: (uploadEdits: UploadEdits) => void
showCrop?: boolean
showFocalPoint?: boolean
}
const defaultCrop: CropType = {
const defaultCrop: UploadEdits['crop'] = {
height: 100,
heightPixels: 0,
unit: '%',
width: 100,
widthPixels: 0,
x: 0,
y: 0,
}
@@ -76,9 +75,9 @@ export const EditUpload: React.FC<EditUploadProps> = ({
const { closeModal } = useModal()
const { t } = useTranslation()
const [crop, setCrop] = useState<CropType>(() => ({
const [crop, setCrop] = useState<UploadEdits['crop']>(() => ({
...defaultCrop,
...initialCrop,
...(initialCrop || {}),
}))
const defaultFocalPosition: FocalPosition = {
@@ -90,31 +89,34 @@ export const EditUpload: React.FC<EditUploadProps> = ({
...defaultFocalPosition,
...initialFocalPoint,
}))
const [checkBounds, setCheckBounds] = useState<boolean>(false)
const [originalHeight, setOriginalHeight] = useState<number>(0)
const [originalWidth, setOriginalWidth] = useState<number>(0)
const [uncroppedPixelHeight, setUncroppedPixelHeight] = useState<number>(0)
const [uncroppedPixelWidth, setUncroppedPixelWidth] = useState<number>(0)
const focalWrapRef = useRef<HTMLDivElement | undefined>(undefined)
const imageRef = useRef<HTMLImageElement | undefined>(undefined)
const cropRef = useRef<HTMLDivElement | undefined>(undefined)
const heightRef = useRef<HTMLInputElement | null>(null)
const widthRef = useRef<HTMLInputElement | null>(null)
const heightInputRef = useRef<HTMLInputElement | null>(null)
const widthInputRef = useRef<HTMLInputElement | null>(null)
const [imageLoaded, setImageLoaded] = useState<boolean>(false)
const onImageLoad = (e) => {
setOriginalHeight(e.currentTarget.naturalHeight)
setOriginalWidth(e.currentTarget.naturalWidth)
// set the default image height/width on load
setUncroppedPixelHeight(e.currentTarget.naturalHeight)
setUncroppedPixelWidth(e.currentTarget.naturalWidth)
setImageLoaded(true)
}
const fineTuneCrop = ({ dimension, value }: { dimension: 'height' | 'width'; value: string }) => {
const intValue = parseInt(value)
if (dimension === 'width' && intValue >= originalWidth) return null
if (dimension === 'height' && intValue >= originalHeight) return null
if (dimension === 'width' && intValue >= uncroppedPixelWidth) return null
if (dimension === 'height' && intValue >= uncroppedPixelHeight) return null
const percentage = 100 * (intValue / (dimension === 'width' ? originalWidth : originalHeight))
const percentage =
100 * (intValue / (dimension === 'width' ? uncroppedPixelWidth : uncroppedPixelHeight))
if (percentage === 100 || percentage === 0) return null
@@ -140,14 +142,10 @@ export const EditUpload: React.FC<EditUploadProps> = ({
const saveEdits = () => {
if (typeof onSave === 'function')
onSave({
crop: crop
? {
...crop,
heightPixels: Number(heightRef.current?.value ?? crop.heightPixels),
widthPixels: Number(widthRef.current?.value ?? crop.widthPixels),
}
: undefined,
focalPosition,
crop: crop ? crop : undefined,
focalPoint: focalPosition,
heightInPixels: Number(heightInputRef?.current?.value ?? uncroppedPixelHeight),
widthInPixels: Number(widthInputRef?.current?.value ?? uncroppedPixelWidth),
})
closeModal(editDrawerSlug)
}
@@ -203,7 +201,7 @@ export const EditUpload: React.FC<EditUploadProps> = ({
className={`${baseClass}__focal-wrapper`}
ref={focalWrapRef}
style={{
aspectRatio: `${originalWidth / originalHeight}`,
aspectRatio: `${uncroppedPixelWidth / uncroppedPixelHeight}`,
}}
>
{showCrop ? (
@@ -259,10 +257,8 @@ export const EditUpload: React.FC<EditUploadProps> = ({
onClick={() =>
setCrop({
height: 100,
heightPixels: originalHeight,
unit: '%',
width: 100,
widthPixels: originalWidth,
x: 0,
y: 0,
})
@@ -279,14 +275,14 @@ export const EditUpload: React.FC<EditUploadProps> = ({
<Input
name={`${t('upload:width')} (px)`}
onChange={(value) => fineTuneCrop({ dimension: 'width', value })}
ref={widthRef}
value={((crop.width / 100) * originalWidth).toFixed(0)}
ref={widthInputRef}
value={((crop.width / 100) * uncroppedPixelWidth).toFixed(0)}
/>
<Input
name={`${t('upload:height')} (px)`}
onChange={(value) => fineTuneCrop({ dimension: 'height', value })}
ref={heightRef}
value={((crop.height / 100) * originalHeight).toFixed(0)}
ref={heightInputRef}
value={((crop.height / 100) * uncroppedPixelHeight).toFixed(0)}
/>
</div>
</div>

View File

@@ -1,16 +1,15 @@
'use client'
import type { FormState, SanitizedCollectionConfig } from 'payload'
import type { FormState, SanitizedCollectionConfig , UploadEdits } from 'payload'
import { useForm, useUploadEdits } from '@payloadcms/ui'
import { isImage, reduceFieldsToValues } from 'payload/shared'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FieldError } from '../../fields/FieldError/index.js'
import { fieldBaseClass } from '../../fields/shared/index.js'
import { useForm } from '../../forms/Form/context.js'
import { useField } from '../../forms/useField/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useFormQueryParams } from '../../providers/FormQueryParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Drawer, DrawerToggler } from '../Drawer/index.js'
@@ -92,14 +91,13 @@ export const Upload: React.FC<UploadProps> = (props) => {
const [fileSrc, setFileSrc] = useState<null | string>(null)
const { t } = useTranslation()
const { setModified } = useForm()
const { dispatchFormQueryParams, formQueryParams } = useFormQueryParams()
const { resetUploadEdits, updateUploadEdits, uploadEdits } = useUploadEdits()
const [doc, setDoc] = useState(reduceFieldsToValues(initialState || {}, true))
const { docPermissions } = useDocumentInfo()
const { errorMessage, setValue, showError, value } = useField<File>({
path: 'file',
validate,
})
const [_crop, setCrop] = useState({ x: 0, y: 0 })
const [showUrlInput, setShowUrlInput] = useState(false)
const [fileUrl, setFileUrl] = useState<string>('')
@@ -167,31 +165,16 @@ export const Upload: React.FC<UploadProps> = (props) => {
setFileSrc('')
setFileUrl('')
setDoc({})
resetUploadEdits()
setShowUrlInput(false)
}, [handleFileChange])
}, [handleFileChange, resetUploadEdits])
const onEditsSave = useCallback(
({ crop, focalPosition }) => {
setCrop({
x: crop.x || 0,
y: crop.y || 0,
})
(args: UploadEdits) => {
setModified(true)
dispatchFormQueryParams({
type: 'SET',
params: {
uploadEdits:
crop || focalPosition
? {
crop: crop || null,
focalPoint: focalPosition ? focalPosition : null,
}
: null,
},
})
updateUploadEdits(args)
},
[dispatchFormQueryParams, setModified],
[setModified, updateUploadEdits],
)
const handlePasteUrlClick = () => {
@@ -342,10 +325,10 @@ export const Upload: React.FC<UploadProps> = (props) => {
fileName={value?.name || doc?.filename}
fileSrc={doc?.url || fileSrc}
imageCacheTag={doc.updatedAt}
initialCrop={formQueryParams?.uploadEdits?.crop ?? {}}
initialCrop={uploadEdits?.crop ?? undefined}
initialFocalPoint={{
x: formQueryParams?.uploadEdits?.focalPoint.x || doc.focalX || 50,
y: formQueryParams?.uploadEdits?.focalPoint.y || doc.focalY || 50,
x: uploadEdits?.focalPoint?.x || doc.focalX || 50,
y: uploadEdits?.focalPoint?.y || doc.focalY || 50,
}}
onSave={onEditsSave}
showCrop={showCrop}

View File

@@ -202,10 +202,7 @@ export {
FieldComponentsProvider,
useFieldComponents,
} from '../../providers/FieldComponents/index.js'
export {
FormQueryParamsProvider,
useFormQueryParams,
} from '../../providers/FormQueryParams/index.js'
export { UploadEditsProvider, useUploadEdits } from '../../providers/UploadEdits/index.js'
export {
type ColumnPreferences,
ListInfoProvider,

View File

@@ -10,7 +10,6 @@ import {
reduceFieldsToValues,
wait,
} from 'payload/shared'
import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { toast } from 'sonner'
@@ -26,7 +25,6 @@ import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useFormQueryParams } from '../../providers/FormQueryParams/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useOperation } from '../../providers/Operation/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
@@ -80,7 +78,6 @@ export const Form: React.FC<FormProps> = (props) => {
const { i18n, t } = useTranslation()
const { refreshCookie, user } = useAuth()
const operation = useOperation()
const { formQueryParams } = useFormQueryParams()
const config = useConfig()
const {
@@ -168,7 +165,7 @@ export const Form: React.FC<FormProps> = (props) => {
const submit = useCallback(
async (options: SubmitOptions = {}, e): Promise<void> => {
const {
action: actionArg,
action: actionArg = action,
method: methodToUse = method,
overrides = {},
skipValidation,
@@ -277,14 +274,9 @@ export const Form: React.FC<FormProps> = (props) => {
try {
let res
const actionEndpoint =
actionArg ||
(typeof action === 'string'
? `${action}${qs.stringify(formQueryParams, { addQueryPrefix: true })}`
: null)
if (actionEndpoint) {
res = await requests[methodToUse.toLowerCase()](actionEndpoint, {
if (typeof actionArg === 'string') {
res = await requests[methodToUse.toLowerCase()](actionArg, {
body: formData,
headers: {
'Accept-Language': i18n.language,
@@ -400,7 +392,6 @@ export const Form: React.FC<FormProps> = (props) => {
t,
i18n,
waitForAutocomplete,
formQueryParams,
],
)
@@ -628,14 +619,9 @@ export const Form: React.FC<FormProps> = (props) => {
[contextRef.current.fields, dispatchFields, onChange, modified],
)
const actionString =
typeof action === 'string'
? `${action}${qs.stringify(formQueryParams, { addQueryPrefix: true })}`
: ''
return (
<form
action={method ? actionString : (action as string)}
action={action}
className={classes}
method={method}
noValidate

View File

@@ -27,6 +27,7 @@ import { useConfig } from '../Config/index.js'
import { useLocale } from '../Locale/index.js'
import { usePreferences } from '../Preferences/index.js'
import { useTranslation } from '../Translation/index.js'
import { UploadEditsProvider, useUploadEdits } from '../UploadEdits/index.js'
const Context = createContext({} as DocumentInfoContext)
@@ -34,7 +35,7 @@ export type * from './types.js'
export const useDocumentInfo = (): DocumentInfoContext => useContext(Context)
export const DocumentInfoProvider: React.FC<
const DocumentInfo: React.FC<
{
children: React.ReactNode
} & DocumentInfoProps
@@ -66,6 +67,8 @@ export const DocumentInfoProvider: React.FC<
const { i18n } = useTranslation()
const { uploadEdits } = useUploadEdits()
const [documentTitle, setDocumentTitle] = useState(() => {
if (!initialDataFromProps) return ''
@@ -104,15 +107,18 @@ export const DocumentInfoProvider: React.FC<
const baseURL = `${serverURL}${api}`
let slug: string
let pluralType: 'collections' | 'globals'
let preferencesKey: string
if (globalSlug) {
slug = globalSlug
pluralType = 'globals'
preferencesKey = `global-${slug}`
}
if (collectionSlug) {
slug = collectionSlug
pluralType = 'collections'
if (id) {
preferencesKey = `collection-${slug}-${id}`
@@ -510,10 +516,25 @@ export const DocumentInfoProvider: React.FC<
data,
])
const action: string = React.useMemo(() => {
const docURL = `${baseURL}${pluralType === 'globals' ? `/globals` : ''}/${slug}${id ? `/${id}` : ''}`
const params = {
depth: 0,
'fallback-locale': 'null',
locale,
uploadEdits: uploadEdits || undefined,
}
return `${docURL}${qs.stringify(params, {
addQueryPrefix: true,
})}`
}, [baseURL, locale, pluralType, id, slug, uploadEdits])
if (isError) notFound()
const value: DocumentInfoContext = {
...props,
action,
docConfig,
docPermissions,
getDocPermissions,
@@ -536,3 +557,15 @@ export const DocumentInfoProvider: React.FC<
return <Context.Provider value={value}>{children}</Context.Provider>
}
export const DocumentInfoProvider: React.FC<
{
children: React.ReactNode
} & DocumentInfoProps
> = (props) => {
return (
<UploadEditsProvider>
<DocumentInfo {...props} />
</UploadEditsProvider>
)
}

View File

@@ -1,69 +0,0 @@
'use client'
import React, { createContext, useContext } from 'react'
import type { Action, FormQueryParamsContext, State } from './types.js'
import { useLocale } from '../Locale/index.js'
export type * from './types.js'
export const FormQueryParams = createContext({} as FormQueryParamsContext)
export const FormQueryParamsProvider: React.FC<{
children: React.ReactNode
initialParams?: State
}> = ({ children, initialParams: formQueryParamsFromProps }) => {
const [formQueryParams, dispatchFormQueryParams] = React.useReducer(
(state: State, action: Action) => {
const newState = { ...state }
switch (action.type) {
case 'SET':
if (action.params?.uploadEdits === null && newState?.uploadEdits) {
delete newState.uploadEdits
}
if (action.params?.uploadEdits?.crop === null && newState?.uploadEdits?.crop) {
delete newState.uploadEdits.crop
}
if (
action.params?.uploadEdits?.focalPoint === null &&
newState?.uploadEdits?.focalPoint
) {
delete newState.uploadEdits.focalPoint
}
return {
...newState,
...action.params,
}
default:
return state
}
},
formQueryParamsFromProps || ({} as State),
)
const locale = useLocale()
React.useEffect(() => {
if (locale?.code) {
dispatchFormQueryParams({
type: 'SET',
params: {
locale: locale.code,
},
})
}
}, [locale.code])
return (
<FormQueryParams.Provider value={{ dispatchFormQueryParams, formQueryParams }}>
{children}
</FormQueryParams.Provider>
)
}
export const useFormQueryParams = (): {
dispatchFormQueryParams: React.Dispatch<Action>
formQueryParams: State
} => useContext(FormQueryParams)

View File

@@ -1,18 +0,0 @@
import type { UploadEdits } from 'payload'
export type FormQueryParamsContext = {
dispatchFormQueryParams: (action: Action) => void
formQueryParams: State
}
export type State = {
depth: number
'fallback-locale': string
locale: string
uploadEdits?: UploadEdits
}
export type Action = {
params: Partial<State>
type: 'SET'
}

View File

@@ -0,0 +1,38 @@
import type { UploadEdits } from 'payload'
import React from 'react'
export type UploadEditsContext = {
resetUploadEdits: () => void
updateUploadEdits: (edits: UploadEdits) => void
uploadEdits: UploadEdits
}
const Context = React.createContext<UploadEditsContext>({
resetUploadEdits: undefined,
updateUploadEdits: undefined,
uploadEdits: undefined,
})
export const UploadEditsProvider = ({ children }) => {
const [uploadEdits, setUploadEdits] = React.useState<UploadEdits>(undefined)
const resetUploadEdits = () => {
setUploadEdits({})
}
const updateUploadEdits = (edits: UploadEdits) => {
setUploadEdits((prevEdits) => ({
...(prevEdits || {}),
...(edits || {}),
}))
}
return (
<Context.Provider value={{ resetUploadEdits, updateUploadEdits, uploadEdits }}>
{children}
</Context.Provider>
)
}
export const useUploadEdits = (): UploadEditsContext => React.useContext(Context)

View File

@@ -30,7 +30,7 @@ export const getFieldSchemaMap = (req: PayloadRequest): FieldSchemaMap => {
}
export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<FormState> => {
const reqData: BuildFormStateArgs = req.data as BuildFormStateArgs
const reqData: BuildFormStateArgs = (req.data || {}) as BuildFormStateArgs
const { collectionSlug, formState, globalSlug, locale, operation, schemaPath } = reqData
const incomingUserSlug = req.user?.collection
@@ -67,7 +67,7 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<
const fieldSchemaMap = getFieldSchemaMap(req)
const id = collectionSlug ? reqData.id : undefined
const schemaPathSegments = schemaPath.split('.')
const schemaPathSegments = schemaPath && schemaPath.split('.')
let fieldSchema: Field[]

View File

@@ -152,6 +152,46 @@ describe('Upload', () => {
await saveDocAndAssert(page)
})
test('should upload after editing image inside a document drawer', async () => {
await uploadImage()
await wait(1000)
// Open the media drawer and create a png upload
await openDocDrawer(page, '.field-type.upload .upload__toggler.doc-drawer__toggler')
await page
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
.setInputFiles(path.resolve(dirname, './uploads/payload.png'))
await expect(
page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'),
).toHaveValue('payload.png')
await page.locator('[id^=doc-drawer_uploads_1_] .file-field__edit').click()
await page
.locator('[id^=edit-upload] .edit-upload__input input[name="Width (px)"]')
.nth(1)
.fill('200')
await page
.locator('[id^=edit-upload] .edit-upload__input input[name="Height (px)"]')
.nth(1)
.fill('200')
await page.locator('[id^=edit-upload] button:has-text("Apply Changes")').nth(1).click()
await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
// Assert that the media field has the png upload
await expect(
page.locator('.field-type.upload .file-details .file-meta__url a'),
).toHaveAttribute('href', '/api/uploads/file/payload-1.png')
await expect(page.locator('.field-type.upload .file-details .file-meta__url a')).toContainText(
'payload-1.png',
)
await expect(page.locator('.field-type.upload .file-details img')).toHaveAttribute(
'src',
'/api/uploads/file/payload-1.png',
)
await saveDocAndAssert(page)
})
test('should clear selected upload', async () => {
await uploadImage()
await wait(1000) // TODO: Fix this. Need to wait a bit until the form in the drawer mounted, otherwise values sometimes disappear. This is an issue for all drawers

View File

@@ -24,6 +24,7 @@ import {
adminThumbnailSizeSlug,
animatedTypeMedia,
audioSlug,
focalOnlySlug,
mediaSlug,
relationSlug,
} from './shared.js'
@@ -41,6 +42,7 @@ let audioURL: AdminUrlUtil
let relationURL: AdminUrlUtil
let adminThumbnailSizeURL: AdminUrlUtil
let adminThumbnailFunctionURL: AdminUrlUtil
let focalOnlyURL: AdminUrlUtil
describe('uploads', () => {
let page: Page
@@ -59,6 +61,7 @@ describe('uploads', () => {
relationURL = new AdminUrlUtil(serverURL, relationSlug)
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
focalOnlyURL = new AdminUrlUtil(serverURL, focalOnlySlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -143,6 +146,25 @@ describe('uploads', () => {
await saveDocAndAssert(page)
})
test('should show proper file names for resized animated file', async () => {
await page.goto(animatedTypeMediaURL.create)
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './animated.webp'))
const animatedFilename = page.locator('.file-field__filename')
await expect(animatedFilename).toHaveValue('animated.webp')
await saveDocAndAssert(page)
await page.locator('.file-field__previewSizes').click()
const smallSquareFilename = page
.locator('.preview-sizes__list .preview-sizes__sizeOption')
.nth(1)
.locator('.file-meta__url a')
await expect(smallSquareFilename).toContainText(/480x480\.webp$/)
})
test('should show resized images', async () => {
await page.goto(mediaURL.edit(pngDoc.id))
@@ -399,5 +421,43 @@ describe('uploads', () => {
expect(greenDoc.filesize).toEqual(1205)
expect(redDoc.filesize).toEqual(1207)
})
test('should update image alignment based on focal point', async () => {
const updateFocalPosition = async (page: Page) => {
await page.goto(focalOnlyURL.create)
await page.waitForURL(focalOnlyURL.create)
// select and upload file
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByText('Select a file').click()
const fileChooser = await fileChooserPromise
await wait(1000)
await fileChooser.setFiles(path.join(dirname, 'horizontal-squares.jpg'))
await page.locator('.file-field__edit').click()
// set focal point
await page.locator('.edit-upload__input input[name="X %"]').fill('12') // left focal point
await page.locator('.edit-upload__input input[name="Y %"]').fill('50') // top focal point
// apply focal point
await page.locator('button:has-text("Apply Changes")').click()
await page.waitForSelector('button#action-save')
await page.locator('button#action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await wait(1000) // Wait for the save
}
await updateFocalPosition(page) // red square
const redSquareMediaID = page.url().split('/').pop() // get the ID of the doc
const { doc: redDoc } = await client.findByID({
id: redSquareMediaID,
slug: focalOnlySlug,
auth: true,
})
// without focal point update this generated size was equal to 1736
expect(redDoc.sizes.focalTest.filesize).toEqual(1598)
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB