fix: uploads from drawer and focal point positioning (#7244)

## Description

V3 PR [here](https://github.com/payloadcms/payload/pull/7117)

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
This commit is contained in:
Patrik
2024-07-19 15:01:06 -04:00
committed by GitHub
parent bd19fcf259
commit 0841d5a35e
41 changed files with 424 additions and 207 deletions

View File

@@ -1,13 +1,12 @@
import { useModal } from '@faceless-ui/modal'
import React, { forwardRef, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactCrop, { type Crop as CropType } from 'react-image-crop'
import ReactCrop from 'react-image-crop'
import 'react-image-crop/dist/ReactCrop.css'
import type { Data } from '../../forms/Form/types'
import type { UploadEdits } from '../../../../uploads/types'
import Plus from '../../icons/Plus'
import { useUploadEdits } from '../../utilities/UploadEdits'
import { editDrawerSlug } from '../../views/collections/Edit/Upload'
import Button from '../Button'
import './index.scss'
@@ -42,58 +41,80 @@ type FocalPosition = {
y: number
}
export const EditUpload: React.FC<{
doc?: Data
export type EditUploadProps = {
fileName: string
fileSrc: string
imageCacheTag?: string
initialCrop?: UploadEdits['crop']
initialFocalPoint?: FocalPosition
onSave?: (uploadEdits: UploadEdits) => void
showCrop?: boolean
showFocalPoint?: boolean
}> = ({ doc, fileName, fileSrc, imageCacheTag, showCrop, showFocalPoint }) => {
}
const defaultCrop: UploadEdits['crop'] = {
height: 100,
unit: '%',
width: 100,
x: 0,
y: 0,
}
export const EditUpload: React.FC<EditUploadProps> = ({
fileName,
fileSrc,
imageCacheTag,
initialCrop,
initialFocalPoint,
onSave,
showCrop,
showFocalPoint,
}) => {
const { closeModal } = useModal()
const { t } = useTranslation(['general', 'upload'])
const { updateUploadEdits, uploadEdits } = useUploadEdits()
const [focalPosition, setFocalPosition] = useState<FocalPosition>({
x: uploadEdits?.focalPoint?.x || doc.focalX || 50,
y: uploadEdits?.focalPoint?.y || doc.focalY || 50,
})
const [crop, setCrop] = useState<UploadEdits['crop']>(() => ({
...defaultCrop,
...(initialCrop || {}),
}))
const defaultFocalPosition: FocalPosition = {
x: 50,
y: 50,
}
const [focalPosition, setFocalPosition] = useState<FocalPosition>(() => ({
...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>()
const imageRef = useRef<HTMLImageElement | undefined>()
const cropRef = useRef<HTMLDivElement | undefined>()
const heightRef = useRef<HTMLInputElement | null>(null)
const widthRef = useRef<HTMLInputElement | null>(null)
const [crop, setCrop] = useState<CropType>({
height: 100,
heightPixels: 0,
unit: '%',
width: 100,
widthPixels: 0,
x: 0,
y: 0,
})
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
@@ -117,16 +138,13 @@ export const EditUpload: React.FC<{
}
const saveEdits = () => {
updateUploadEdits({
crop: crop
? {
...crop,
heightPixels: Number(heightRef.current?.value ?? crop.heightPixels),
widthPixels: Number(widthRef.current?.value ?? crop.widthPixels),
}
: undefined,
focalPoint: focalPosition ? focalPosition : undefined,
})
if (typeof onSave === 'function')
onSave({
crop: crop ? crop : undefined,
focalPoint: focalPosition,
heightInPixels: Number(heightInputRef?.current?.value ?? uncroppedPixelHeight),
widthInPixels: Number(widthInputRef?.current?.value ?? uncroppedPixelWidth),
})
closeModal(editDrawerSlug)
}
@@ -181,7 +199,7 @@ export const EditUpload: React.FC<{
className={`${baseClass}__focal-wrapper`}
ref={focalWrapRef}
style={{
aspectRatio: `${originalWidth / originalHeight}`,
aspectRatio: `${uncroppedPixelWidth / uncroppedPixelHeight}`,
}}
>
{showCrop ? (
@@ -237,10 +255,8 @@ export const EditUpload: React.FC<{
onClick={() =>
setCrop({
height: 100,
heightPixels: originalHeight,
unit: '%',
width: 100,
widthPixels: originalWidth,
x: 0,
y: 0,
})
@@ -257,14 +273,14 @@ export const EditUpload: React.FC<{
<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

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import type { UploadEdits } from '../../../../../../uploads/types'
import type { Props } from './types'
import isImage from '../../../../../../uploads/isImage'
@@ -13,6 +14,7 @@ import FileDetails from '../../../../elements/FileDetails'
import PreviewSizes from '../../../../elements/PreviewSizes'
import Thumbnail from '../../../../elements/Thumbnail'
import Error from '../../../../forms/Error'
import { useForm } from '../../../../forms/Form/context'
import reduceFieldsToValues from '../../../../forms/Form/reduceFieldsToValues'
import { fieldBaseClass } from '../../../../forms/field-types/shared'
import useField from '../../../../forms/useField'
@@ -55,7 +57,8 @@ export const Upload: React.FC<Props> = (props) => {
const [replacingFile, setReplacingFile] = useState(false)
const [fileSrc, setFileSrc] = useState<null | string>(null)
const { t } = useTranslation(['upload', 'general'])
const { resetUploadEdits } = useUploadEdits()
const { setModified } = useForm()
const { resetUploadEdits, updateUploadEdits, uploadEdits } = useUploadEdits()
const [doc, setDoc] = useState(reduceFieldsToValues(internalState || {}, true))
const { docPermissions } = useDocumentInfo()
const { errorMessage, setValue, showError, value } = useField<File>({
@@ -133,6 +136,14 @@ export const Upload: React.FC<Props> = (props) => {
setShowUrlInput(false)
}, [handleFileChange, resetUploadEdits])
const onEditsSave = useCallback(
(args: UploadEdits) => {
setModified(true)
updateUploadEdits(args)
},
[setModified, updateUploadEdits],
)
const handlePasteUrlClick = () => {
setShowUrlInput((prev) => !prev)
}
@@ -222,7 +233,7 @@ export const Upload: React.FC<Props> = (props) => {
onClick={handleUrlSubmit}
type="button"
>
{t('upload:addImage')}
{t('upload:addFile')}
</button>
</div>
</div>
@@ -274,10 +285,15 @@ export const Upload: React.FC<Props> = (props) => {
{(value || doc.filename) && (
<Drawer header={null} slug={editDrawerSlug}>
<EditUpload
doc={doc || undefined}
fileName={value?.name || doc?.filename}
fileSrc={doc?.url || fileSrc}
imageCacheTag={doc.updatedAt}
initialCrop={uploadEdits?.crop ?? undefined}
initialFocalPoint={{
x: uploadEdits?.focalPoint?.x || doc.focalX || 50,
y: uploadEdits?.focalPoint?.y || doc.focalY || 50,
}}
onSave={onEditsSave}
showCrop={showCrop}
showFocalPoint={showFocalPoint}
/>

View File

@@ -4,15 +4,3 @@ export type IndexProps = {
collection: SanitizedCollectionConfig
isEditing?: boolean
}
export type UploadEdits = {
crop?: {
height?: number
width?: number
x?: number
y?: number
}
focalPoint?: {
x?: number
y?: number
}
}

View File

@@ -283,7 +283,7 @@
"near": "قريب من"
},
"upload": {
"addImage": "إضافة صورة",
"addFile": "إضافة ملف",
"crop": "محصول",
"cropToolDescription": "اسحب الزوايا المحددة للمنطقة، رسم منطقة جديدة أو قم بضبط القيم أدناه.",
"dragAndDrop": "قم بسحب وإسقاط ملفّ",

View File

@@ -283,7 +283,7 @@
"near": "yaxın"
},
"upload": {
"addImage": "Şəkil əlavə et",
"addFile": "Fayl əlavə et",
"crop": "Məhsul",
"cropToolDescription": "Seçilmiş sahənin köşələrini sürükləyin, yeni bir sahə çəkin və ya aşağıdakı dəyərləri düzəltin.",
"dragAndDrop": "Faylı buraya sürükləyin və buraxın",

View File

@@ -283,7 +283,7 @@
"near": "близко"
},
"upload": {
"addImage": "Добавяне на изображение",
"addFile": "Добавяне на файл",
"crop": "Изрязване",
"cropToolDescription": "Плъзни ъглите на избраната област, избери нова област или коригирай стойностите по-долу.",
"dragAndDrop": "Дръпни и пусни файл",

View File

@@ -283,7 +283,7 @@
"near": "blízko"
},
"upload": {
"addImage": "Přidat obrázek",
"addFile": "Přidat soubor",
"crop": "Ořez",
"cropToolDescription": "Přetáhněte rohy vybrané oblasti, nakreslete novou oblast nebo upravte níže uvedené hodnoty.",
"dragAndDrop": "Přetáhněte soubor",

View File

@@ -283,7 +283,7 @@
"near": "in der Nähe"
},
"upload": {
"addImage": "Bild hinzufügen",
"addFile": "Datei hinzufügen",
"crop": "Zuschneiden",
"cropToolDescription": "Ziehen Sie die Ecken des ausgewählten Bereichs, zeichnen Sie einen neuen Bereich oder passen Sie die Werte unten an.",
"dragAndDrop": "Ziehen Sie eine Datei per Drag-and-Drop",

View File

@@ -283,7 +283,7 @@
"near": "near"
},
"upload": {
"addImage": "Add Image",
"addFile": "Add File",
"crop": "Crop",
"cropToolDescription": "Drag the corners of the selected area, draw a new area or adjust the values below.",
"dragAndDrop": "Drag and drop a file",

View File

@@ -283,7 +283,7 @@
"near": "cerca"
},
"upload": {
"addImage": "Añadir imagen",
"addFile": "Añadir archivo",
"crop": "Cultivo",
"cropToolDescription": "Arrastra las esquinas del área seleccionada, dibuja un nuevo área o ajusta los valores a continuación.",
"dragAndDrop": "Arrastra y suelta un archivo",

View File

@@ -283,7 +283,7 @@
"near": "نزدیک"
},
"upload": {
"addImage": "اضافه کردن تصویر",
"addFile": "اضافه کردن فایل",
"crop": "محصول",
"cropToolDescription": "گوشه‌های منطقه انتخاب شده را بکشید، یک منطقه جدید رسم کنید یا مقادیر زیر را تنظیم کنید.",
"dragAndDrop": "یک سند را بکشید و رها کنید",

View File

@@ -283,7 +283,7 @@
"near": "proche"
},
"upload": {
"addImage": "Ajouter une image",
"addFile": "Ajouter un fichier",
"crop": "Recadrer",
"cropToolDescription": "Faites glisser les coins de la zone sélectionnée, dessinez une nouvelle zone ou ajustez les valeurs ci-dessous.",
"dragAndDrop": "Glisser-déposer un fichier",

View File

@@ -283,7 +283,7 @@
"near": "blizu"
},
"upload": {
"addImage": "Dodaj sliku",
"addFile": "Dodaj datoteku",
"crop": "Usjev",
"cropToolDescription": "Povucite kutove odabranog područja, nacrtajte novo područje ili prilagodite vrijednosti ispod.",
"dragAndDrop": "Povucite i ispustite datoteku",

View File

@@ -283,7 +283,7 @@
"near": "közel"
},
"upload": {
"addImage": "Kép hozzáadása",
"addFile": "Fájl hozzáadása",
"crop": "Termés",
"cropToolDescription": "Húzza a kijelölt terület sarkait, rajzoljon új területet, vagy igazítsa a lentebb található értékeket.",
"dragAndDrop": "Húzzon ide egy fájlt",

View File

@@ -284,7 +284,7 @@
"near": "vicino"
},
"upload": {
"addImage": "Aggiungi immagine",
"addFile": "Aggiungi file",
"crop": "Raccolto",
"cropToolDescription": "Trascina gli angoli dell'area selezionata, disegna una nuova area o regola i valori qui sotto.",
"dragAndDrop": "Trascina e rilascia un file",

View File

@@ -283,7 +283,7 @@
"near": "近く"
},
"upload": {
"addImage": "画像を追加",
"addFile": "ファイルを追加",
"crop": "クロップ",
"cropToolDescription": "選択したエリアのコーナーをドラッグしたり、新たなエリアを描画したり、下記の値を調整してください。",
"dragAndDrop": "ファイルをドラッグ アンド ドロップする",

View File

@@ -283,7 +283,7 @@
"near": "근처"
},
"upload": {
"addImage": "이미지 추가",
"addFile": "파일 추가",
"crop": "자르기",
"cropToolDescription": "선택한 영역의 모퉁이를 드래그하거나 새로운 영역을 그리거나 아래의 값을 조정하세요.",
"dragAndDrop": "파일을 끌어다 놓으세요",

View File

@@ -283,7 +283,7 @@
"near": "နီး"
},
"upload": {
"addImage": "ပုံ ထည့်ပါ",
"addFile": "ဖိုင်ထည့်ပါ",
"crop": "သုန်း",
"cropToolDescription": "ရွေးထားသည့်ဧရိယာတွင်မွေးလျှက်မှုများကိုဆွဲပြီး, အသစ်တည်ပြီးသို့မဟုတ်အောက်ပါတ",
"dragAndDrop": "ဖိုင်တစ်ဖိုင်ကို ဆွဲချလိုက်ပါ။",

View File

@@ -283,7 +283,7 @@
"near": "nær"
},
"upload": {
"addImage": "Legg til bilde",
"addFile": "Legg til fil",
"crop": "Beskjær",
"cropToolDescription": "Dra hjørnene av det valgte området, tegn et nytt område eller juster verdiene nedenfor.",
"dragAndDrop": "Dra og slipp en fil",

View File

@@ -283,7 +283,7 @@
"near": "nabij"
},
"upload": {
"addImage": "Afbeelding toevoegen",
"addFile": "Bestand toevoegen",
"crop": "Bijsnijden",
"cropToolDescription": "Sleep de hoeken van het geselecteerde gebied, teken een nieuw gebied of pas de waarden hieronder aan.",
"dragAndDrop": "Sleep een bestand",

View File

@@ -284,7 +284,7 @@
"near": "blisko"
},
"upload": {
"addImage": "Dodaj obraz",
"addFile": "Dodaj plik",
"crop": "Przytnij",
"cropToolDescription": "Przeciągnij narożniki wybranego obszaru, narysuj nowy obszar lub dostosuj poniższe wartości.",
"dragAndDrop": "Przeciągnij i upuść plik",

View File

@@ -283,7 +283,7 @@
"near": "perto"
},
"upload": {
"addImage": "Adicionar imagem",
"addFile": "Adicionar arquivo",
"crop": "Cultura",
"cropToolDescription": "Arraste as bordas da área selecionada, desenhe uma nova área ou ajuste os valores abaixo.",
"dragAndDrop": "Arraste e solte um arquivo",

View File

@@ -283,7 +283,7 @@
"near": "în apropiere de"
},
"upload": {
"addImage": "Adaugă imagine",
"addFile": "Adaugă fișier",
"crop": "Cultură",
"cropToolDescription": "Trageți colțurile zonei selectate, desenați o nouă zonă sau ajustați valorile de mai jos.",
"dragAndDrop": "Trageți și plasați un fișier",

View File

@@ -283,7 +283,7 @@
"near": "blizu"
},
"upload": {
"addImage": "Dodaj sliku",
"addFile": "Dodaj datoteku",
"crop": "Isecite sliku",
"cropToolDescription": "Prevucite uglove izabranog područja, nacrtajte novo područje ili prilagodite vrednosti ispod.",
"dragAndDrop": "Prevucite i ispustite datoteku",

View File

@@ -283,7 +283,7 @@
"near": "близу"
},
"upload": {
"addImage": "Додај слику",
"addFile": "Додај датотеку",
"crop": "Исеците слику",
"cropToolDescription": "Превуците углове изабраног подручја, нацртајте ново подручје или прилагодите вредности испод.",
"dragAndDrop": "Превуците и испустите датотеку",

View File

@@ -283,7 +283,7 @@
"near": "рядом"
},
"upload": {
"addImage": "Добавить изображение",
"addFile": "Добавить файл",
"crop": "Обрезать",
"cropToolDescription": "Перетащите углы выбранной области, нарисуйте новую область или отрегулируйте значения ниже.",
"dragAndDrop": "Перетащите файл",

View File

@@ -283,7 +283,7 @@
"near": "nära"
},
"upload": {
"addImage": "Lägg till bild",
"addFile": "Lägg till fil",
"crop": "Skörd",
"cropToolDescription": "Dra i hörnen på det valda området, rita ett nytt område eller justera värdena nedan.",
"dragAndDrop": "Dra och släpp en fil",

View File

@@ -283,7 +283,7 @@
"near": "ใกล้"
},
"upload": {
"addImage": "เพิ่มรูปภาพ",
"addFile": "เพิ่มไฟล์",
"crop": "พืชผล",
"cropToolDescription": "ลากมุมของพื้นที่ที่เลือก, วาดพื้นที่ใหม่หรือปรับค่าด้านล่าง",
"dragAndDrop": "ลากและวางไฟล์",

View File

@@ -283,7 +283,7 @@
"near": "yakın"
},
"upload": {
"addImage": "Resim ekle",
"addFile": "Dosya ekle",
"crop": "Kırp",
"cropToolDescription": "Seçili alanın köşelerini sürükleyin, yeni bir alan çizin veya aşağıdaki değerleri ayarlayın.",
"dragAndDrop": "Bir dosyayı sürükleyip bırakın",

View File

@@ -1108,7 +1108,7 @@
"upload": {
"additionalProperties": false,
"properties": {
"addImage": {
"addFile": {
"type": "string"
},
"crop": {

View File

@@ -283,7 +283,7 @@
"near": "поруч"
},
"upload": {
"addImage": "Додати зображення",
"addFile": "Додати файл",
"crop": "Обрізати",
"cropToolDescription": "Перетягніть кути обраної області, намалюйте нову область або скоригуйте значення нижче.",
"dragAndDrop": "Перемістіть файл",

View File

@@ -283,7 +283,7 @@
"near": "gần"
},
"upload": {
"addImage": "Thêm hình ảnh",
"addFile": "Thêm tập tin",
"crop": "Mùa vụ",
"cropToolDescription": "Kéo các góc của khu vực đã chọn, vẽ một khu vực mới hoặc điều chỉnh các giá trị dưới đây.",
"dragAndDrop": "Kéo và thả một tập tin",

View File

@@ -283,7 +283,7 @@
"near": "附近"
},
"upload": {
"addImage": "添加圖片",
"addFile": "添加文件",
"crop": "裁剪",
"cropToolDescription": "拖動所選區域的角落,繪製一個新區域或調整以下的值。",
"dragAndDrop": "拖放一個檔案",

View File

@@ -283,7 +283,7 @@
"near": "附近"
},
"upload": {
"addImage": "添加图片",
"addFile": "添加文件",
"crop": "作物",
"cropToolDescription": "拖动所选区域的角落,绘制一个新区域或调整以下的值。",
"dragAndDrop": "拖放一个文件",

View File

@@ -1,26 +1,43 @@
import type { UploadedFile } from 'express-fileupload'
import type { SharpOptions } from 'sharp'
import sharp from 'sharp'
export const percentToPixel = (value: string, dimension: number): number => {
import type { UploadEdits } from './types'
export const percentToPixel = (value, dimension): number => {
if (!value) return 0
return Math.floor((parseFloat(value) / 100) * dimension)
}
export default async function cropImage({ cropData, dimensions, file }) {
try {
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
const { heightPixels, widthPixels, x, y } = cropData
type CropImageArgs = {
cropData: UploadEdits['crop']
dimensions: { height: number; width: number }
file: UploadedFile
heightInPixels: number
widthInPixels: number
}
export async function cropImage({
cropData,
dimensions,
file,
heightInPixels,
widthInPixels,
}: CropImageArgs) {
try {
const { x, y } = cropData
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
const sharpOptions: SharpOptions = {}
if (fileIsAnimatedType) sharpOptions.animated = true
const formattedCropData: sharp.Region = {
height: Number(heightPixels),
const formattedCropData = {
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

@@ -16,7 +16,7 @@ import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types
import { FileUploadError, MissingFile } from '../errors'
import FileRetrievalError from '../errors/FileRetrievalError'
import canResizeImage from './canResizeImage'
import cropImage from './cropImage'
import { cropImage } from './cropImage'
import { getExternalFile } from './getExternalFile'
import getFileByPath from './getFileByPath'
import getImageSize from './getImageSize'
@@ -211,7 +211,13 @@ export const generateFileData = async <T>({
let fileForResize = file
if (cropData) {
const { data: croppedImage, info } = await cropImage({ cropData, dimensions, file })
const { data: croppedImage, info } = await cropImage({
cropData,
dimensions,
file,
heightInPixels: uploadEdits.heightInPixels,
widthInPixels: uploadEdits.widthInPixels,
})
filesToSave.push({
buffer: croppedImage,

View File

@@ -1,5 +1,5 @@
import type { UploadedFile } from 'express-fileupload'
import type { OutputInfo, Sharp, SharpOptions } from 'sharp'
import type { Sharp, Metadata as SharpMetadata, SharpOptions } from 'sharp'
import { fromBuffer } from 'file-type'
import fs from 'fs'
@@ -68,11 +68,21 @@ 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,46 +132,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
* `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
*
* `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 preventResize = (
{ height: desiredHeight, width: desiredWidth, withoutEnlargement, withoutReduction }: ImageSize,
original: ProbedImageSize,
): boolean => {
// default is to allow reduction
if (withoutReduction !== undefined) {
return false // needs resize
const getImageResizeAction = ({
dimensions: originalImage,
hasFocalPoint,
imageResizeConfig,
}: {
dimensions: ProbedImageSize
hasFocalPoint?: boolean
imageResizeConfig: ImageSize
}): 'omit' | 'resize' | 'resizeWithFocalPoint' => {
const {
fit,
height: targetHeight,
width: targetWidth,
withoutEnlargement,
withoutReduction,
} = imageResizeConfig
// 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
}
}
// default is to prevent enlargement
if (withoutEnlargement !== undefined) {
return false // needs resize
}
const originalImageIsSmallerXOrY =
originalImage.width < targetWidth || originalImage.height < targetHeight
if (fit === 'contain' || fit === 'inside') return 'resize'
if (!isNumber(targetHeight) && !isNumber(targetWidth)) return '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 targetAspectRatio = targetWidth / targetHeight
const originalAspectRatio = originalImage.width / originalImage.height
if (originalAspectRatio === targetAspectRatio) return '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
}
if (withoutEnlargement && originalImageIsSmallerXOrY) return 'resize'
if (withoutReduction && !originalImageIsSmallerXOrY) return 'resize'
return false // needs resize
return hasFocalPoint ? 'resizeWithFocalPoint' : 'resize'
}
/**
@@ -209,6 +234,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.
@@ -259,24 +297,28 @@ export default 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
@@ -291,44 +333,62 @@ export default 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)
@@ -357,11 +417,15 @@ export default async function resizeAndTransformImageSizes({
const mimeInfo = await fromBuffer(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}`
@@ -378,7 +442,8 @@ export default 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,
@@ -386,9 +451,12 @@ export default async function resizeAndTransformImageSizes({
}),
)
return results.reduce((acc, result) => {
Object.assign(acc.sizeData, result.sizeData)
acc.sizesToSave.push(...result.sizesToSave)
return acc
}, defaultResult)
return results.reduce(
(acc, result) => {
Object.assign(acc.sizeData, result.sizeData)
acc.sizesToSave.push(...result.sizesToSave)
return acc
},
{ ...defaultResult },
)
}

View File

@@ -124,15 +124,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
}