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, useFormInitializing,
useFormModified, useFormModified,
useFormProcessing, useFormProcessing,
useFormQueryParams,
useFormSubmitted, useFormSubmitted,
useHotkey, useHotkey,
useIntersect, useIntersect,
@@ -221,7 +220,6 @@ import {
EntityVisibilityProvider, EntityVisibilityProvider,
FieldComponentsProvider, FieldComponentsProvider,
FieldPropsProvider, FieldPropsProvider,
FormQueryParamsProvider,
ListInfoProvider, ListInfoProvider,
ListQueryProvider, ListQueryProvider,
LocaleProvider, LocaleProvider,

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,31 @@
import type { SharpOptions } from 'sharp' 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) => { export const percentToPixel = (value, dimension) => {
return Math.floor((parseFloat(value) / 100) * 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 { try {
const { heightPixels, widthPixels, x, y } = cropData const { x, y } = cropData
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) 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 if (fileIsAnimatedType) sharpOptions.animated = true
const formattedCropData = { const formattedCropData = {
height: Number(heightPixels), height: Number(heightInPixels),
left: percentToPixel(x, dimensions.width), left: percentToPixel(x, dimensions.width),
top: percentToPixel(y, dimensions.height), top: percentToPixel(y, dimensions.height),
width: Number(widthPixels), width: Number(widthInPixels),
} }
const cropped = sharp(file.tempFilePath || file.data, sharpOptions).extract(formattedCropData) const cropped = sharp(file.tempFilePath || file.data, sharpOptions).extract(formattedCropData)

View File

@@ -203,7 +203,14 @@ export const generateFileData = async <T>({
let fileForResize = file let fileForResize = file
if (cropData && sharp) { 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({ filesToSave.push({
buffer: croppedImage, 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 { fileTypeFromBuffer } from 'file-type'
import fs from 'fs' import fs from 'fs'
@@ -68,11 +68,20 @@ const getSanitizedImageData = (sourceImage: string): SanitizedImageData => {
* @param extension - the extension to use * @param extension - the extension to use
* @returns the new image name that is not taken * @returns the new image name that is not taken
*/ */
const createImageName = ( type CreateImageNameArgs = {
outputImageName: string, extension: string
{ height, width }: OutputInfo, height: number
extension: string, outputImageName: string
) => `${outputImageName}-${width}x${height}.${extension}` width: number
}
const createImageName = ({
extension,
height,
outputImageName,
width,
}: CreateImageNameArgs): string => {
return `${outputImageName}-${width}x${height}.${extension}`
}
type CreateResultArgs = { type CreateResultArgs = {
filename?: FileSize['filename'] filename?: FileSize['filename']
@@ -122,71 +131,61 @@ const createResult = ({
} }
/** /**
* Check if the image needs to be resized according to the requested dimensions * Determine whether or not to resize the image.
* and the original image size. If the resize options withoutEnlargement or withoutReduction are provided, * - resize using image config
* the image will be resized regardless of the requested dimensions, given that the * - resize using image config with focal adjustments
* width or height to be resized is provided. * - do not resize at all
* *
* @param resizeConfig - object containing the requested dimensions and resize options * `imageResizeConfig.withoutEnlargement`:
* @param original - the original image size * - undefined [default]: uploading images with smaller width AND height than the image size will return null
* @returns true if resizing is not needed, false otherwise * - false: always enlarge images to the image size
*/ * - true: if the image is smaller than the image size, return the original image
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.
* *
* @param resizeConfig - object containing the requested dimensions and resize options * `imageResizeConfig.withoutReduction`:
* @param original - the original image size * - false [default]: always enlarge images to the image size
* @returns true if the image should passed directly to sharp * - true: if the image is smaller than the image size, return the original image
*
* @return 'omit' | 'resize' | 'resizeWithFocalPoint'
*/ */
const applyPayloadAdjustments = ( const getImageResizeAction = ({
{ fit, height, width, withoutEnlargement, withoutReduction }: ImageSize, dimensions: originalImage,
original: ProbedImageSize, hasFocalPoint,
) => { imageResizeConfig,
if (fit === 'contain' || fit === 'inside') return false }: {
if (!isNumber(height) && !isNumber(width)) return false dimensions: ProbedImageSize
hasFocalPoint?: boolean
imageResizeConfig: ImageSize
}): 'omit' | 'resize' | 'resizeWithFocalPoint' => {
const {
fit,
height: targetHeight,
width: targetWidth,
withoutEnlargement,
withoutReduction,
} = imageResizeConfig
const targetAspectRatio = width / height // prevent upscaling by default when x and y are both smaller than target image size
const originalAspectRatio = original.width / original.height if (targetHeight && targetWidth) {
if (originalAspectRatio === targetAspectRatio) return false 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 originalImageIsSmallerXOrY =
const skipReduction = withoutReduction && (original.height > height || original.width > width) originalImage.width < targetWidth || originalImage.height < targetHeight
if (skipEnlargement || skipReduction) return false 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 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 * For the provided image sizes, handle the resizing and the transforms
* (format, trim, etc.) of each requested image size and return the result object. * (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 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 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( const results: ImageSizesResult[] = await Promise.all(
imageSizes.map(async (imageResizeConfig): Promise<ImageSizesResult> => { imageSizes.map(async (imageResizeConfig): Promise<ImageSizesResult> => {
imageResizeConfig = sanitizeResizeConfig(imageResizeConfig) imageResizeConfig = sanitizeResizeConfig(imageResizeConfig)
// This checks if a resize should happen. If not, the resized image will be const resizeAction = getImageResizeAction({
// skipped COMPLETELY and thus will not be included in the resulting images. dimensions,
// All further format/trim options will thus be skipped as well. hasFocalPoint: Boolean(incomingFocalPoint),
if (preventResize(imageResizeConfig, dimensions)) { imageResizeConfig,
return createResult({ name: imageResizeConfig.name }) })
} if (resizeAction === 'omit') return createResult({ name: imageResizeConfig.name })
const imageToResize = sharpBase.clone() const imageToResize = sharpBase.clone()
let resized = imageToResize let resized = imageToResize
const metadata = await sharpBase.metadata() if (resizeAction === 'resizeWithFocalPoint') {
if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) {
let { height: resizeHeight, width: resizeWidth } = imageResizeConfig let { height: resizeHeight, width: resizeWidth } = imageResizeConfig
const originalAspectRatio = dimensions.width / dimensions.height const originalAspectRatio = dimensions.width / dimensions.height
@@ -293,44 +309,62 @@ export async function resizeAndTransformImageSizes({
resizeHeight = Math.round(resizeWidth / originalAspectRatio) resizeHeight = Math.round(resizeWidth / originalAspectRatio)
} }
// Scale the image up or down to fit the resize dimensions if (!resizeHeight) resizeHeight = resizeImageMeta.height
const scaledImage = imageToResize.resize({ if (!resizeWidth) resizeWidth = resizeImageMeta.width
height: resizeHeight,
width: resizeWidth, // 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 { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true }) // must read from buffer, resize.metadata will return the original image metadata
const { info } = await resized.toBuffer({ resolveWithObject: true })
const safeResizeWidth = resizeWidth ?? scaledImageInfo.width resizeImageMeta.height = extractHeightFromImage({
const maxOffsetX = scaledImageInfo.width - safeResizeWidth ...originalImageMeta,
const leftFocalEdge = Math.round( height: info.height,
scaledImageInfo.width * (incomingFocalPoint.x / 100) - safeResizeWidth / 2, })
) resizeImageMeta.width = info.width
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
} }
const maxOffsetY = isAnimated const halfResizeX = resizeWidth / 2
? safeResizeHeight - (resizeHeight ?? safeResizeHeight) const xFocalCenter = resizeImageMeta.width * (incomingFocalPoint.x / 100)
: scaledImageInfo.height - safeResizeHeight const calculatedRightPixelBound = xFocalCenter + halfResizeX
let leftBound = xFocalCenter - halfResizeX
const topFocalEdge = Math.round( // if the right bound is greater than the image width, adjust the left bound
scaledImageInfo.height * (incomingFocalPoint.y / 100) - safeResizeHeight / 2, // keeping focus on the right
) if (calculatedRightPixelBound > resizeImageMeta.width) {
const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY) leftBound = resizeImageMeta.width - resizeWidth
}
// extract the focal area from the scaled image // if the left bound is less than 0, adjust the left bound to 0
resized = (fileIsAnimatedType ? imageToResize : scaledImage).extract({ // keeping the focus on the left
height: safeResizeHeight, if (leftBound < 0) leftBound = 0
left: safeOffsetX,
top: safeOffsetY, const halfResizeY = resizeHeight / 2
width: safeResizeWidth, 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 { } else {
resized = imageToResize.resize(imageResizeConfig) resized = imageToResize.resize(imageResizeConfig)
@@ -359,11 +393,15 @@ export async function resizeAndTransformImageSizes({
const mimeInfo = await fileTypeFromBuffer(bufferData) const mimeInfo = await fileTypeFromBuffer(bufferData)
const imageNameWithDimensions = createImageName( const imageNameWithDimensions = createImageName({
sanitizedImage.name, extension: mimeInfo?.ext || sanitizedImage.ext,
bufferInfo, height: extractHeightFromImage({
mimeInfo?.ext || sanitizedImage.ext, ...originalImageMeta,
) height: bufferInfo.height,
}),
outputImageName: sanitizedImage.name,
width: bufferInfo.width,
})
const imagePath = `${staticPath}/${imageNameWithDimensions}` const imagePath = `${staticPath}/${imageNameWithDimensions}`
@@ -380,7 +418,8 @@ export async function resizeAndTransformImageSizes({
name: imageResizeConfig.name, name: imageResizeConfig.name,
filename: imageNameWithDimensions, filename: imageNameWithDimensions,
filesize: size, filesize: size,
height: fileIsAnimatedType && metadata.pages ? height / metadata.pages : height, height:
fileIsAnimatedType && originalImageMeta.pages ? height / originalImageMeta.pages : height,
mimeType: mimeInfo?.mime || mimeType, mimeType: mimeInfo?.mime || mimeType,
sizesToSave: [{ buffer: bufferData, path: imagePath }], sizesToSave: [{ buffer: bufferData, path: imagePath }],
width, width,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import { useConfig } from '../Config/index.js'
import { useLocale } from '../Locale/index.js' import { useLocale } from '../Locale/index.js'
import { usePreferences } from '../Preferences/index.js' import { usePreferences } from '../Preferences/index.js'
import { useTranslation } from '../Translation/index.js' import { useTranslation } from '../Translation/index.js'
import { UploadEditsProvider, useUploadEdits } from '../UploadEdits/index.js'
const Context = createContext({} as DocumentInfoContext) const Context = createContext({} as DocumentInfoContext)
@@ -34,7 +35,7 @@ export type * from './types.js'
export const useDocumentInfo = (): DocumentInfoContext => useContext(Context) export const useDocumentInfo = (): DocumentInfoContext => useContext(Context)
export const DocumentInfoProvider: React.FC< const DocumentInfo: React.FC<
{ {
children: React.ReactNode children: React.ReactNode
} & DocumentInfoProps } & DocumentInfoProps
@@ -66,6 +67,8 @@ export const DocumentInfoProvider: React.FC<
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { uploadEdits } = useUploadEdits()
const [documentTitle, setDocumentTitle] = useState(() => { const [documentTitle, setDocumentTitle] = useState(() => {
if (!initialDataFromProps) return '' if (!initialDataFromProps) return ''
@@ -104,15 +107,18 @@ export const DocumentInfoProvider: React.FC<
const baseURL = `${serverURL}${api}` const baseURL = `${serverURL}${api}`
let slug: string let slug: string
let pluralType: 'collections' | 'globals'
let preferencesKey: string let preferencesKey: string
if (globalSlug) { if (globalSlug) {
slug = globalSlug slug = globalSlug
pluralType = 'globals'
preferencesKey = `global-${slug}` preferencesKey = `global-${slug}`
} }
if (collectionSlug) { if (collectionSlug) {
slug = collectionSlug slug = collectionSlug
pluralType = 'collections'
if (id) { if (id) {
preferencesKey = `collection-${slug}-${id}` preferencesKey = `collection-${slug}-${id}`
@@ -510,10 +516,25 @@ export const DocumentInfoProvider: React.FC<
data, 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() if (isError) notFound()
const value: DocumentInfoContext = { const value: DocumentInfoContext = {
...props, ...props,
action,
docConfig, docConfig,
docPermissions, docPermissions,
getDocPermissions, getDocPermissions,
@@ -536,3 +557,15 @@ export const DocumentInfoProvider: React.FC<
return <Context.Provider value={value}>{children}</Context.Provider> 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> => { 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 { collectionSlug, formState, globalSlug, locale, operation, schemaPath } = reqData
const incomingUserSlug = req.user?.collection const incomingUserSlug = req.user?.collection
@@ -67,7 +67,7 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<
const fieldSchemaMap = getFieldSchemaMap(req) const fieldSchemaMap = getFieldSchemaMap(req)
const id = collectionSlug ? reqData.id : undefined const id = collectionSlug ? reqData.id : undefined
const schemaPathSegments = schemaPath.split('.') const schemaPathSegments = schemaPath && schemaPath.split('.')
let fieldSchema: Field[] let fieldSchema: Field[]

View File

@@ -152,6 +152,46 @@ describe('Upload', () => {
await saveDocAndAssert(page) 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 () => { test('should clear selected upload', async () => {
await uploadImage() 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 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, adminThumbnailSizeSlug,
animatedTypeMedia, animatedTypeMedia,
audioSlug, audioSlug,
focalOnlySlug,
mediaSlug, mediaSlug,
relationSlug, relationSlug,
} from './shared.js' } from './shared.js'
@@ -41,6 +42,7 @@ let audioURL: AdminUrlUtil
let relationURL: AdminUrlUtil let relationURL: AdminUrlUtil
let adminThumbnailSizeURL: AdminUrlUtil let adminThumbnailSizeURL: AdminUrlUtil
let adminThumbnailFunctionURL: AdminUrlUtil let adminThumbnailFunctionURL: AdminUrlUtil
let focalOnlyURL: AdminUrlUtil
describe('uploads', () => { describe('uploads', () => {
let page: Page let page: Page
@@ -59,6 +61,7 @@ describe('uploads', () => {
relationURL = new AdminUrlUtil(serverURL, relationSlug) relationURL = new AdminUrlUtil(serverURL, relationSlug)
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug) adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug) adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
focalOnlyURL = new AdminUrlUtil(serverURL, focalOnlySlug)
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -143,6 +146,25 @@ describe('uploads', () => {
await saveDocAndAssert(page) 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 () => { test('should show resized images', async () => {
await page.goto(mediaURL.edit(pngDoc.id)) await page.goto(mediaURL.edit(pngDoc.id))
@@ -399,5 +421,43 @@ describe('uploads', () => {
expect(greenDoc.filesize).toEqual(1205) expect(greenDoc.filesize).toEqual(1205)
expect(redDoc.filesize).toEqual(1207) 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